From e7c9284557df685a3d40abf410868a434d41c60b Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Sat, 27 Dec 2025 02:16:05 +0200 Subject: [PATCH] complete story 5.5 with qa tests --- docs/qa/gates/5.5-post-search.yml | 52 +++++ docs/stories/story-5.5-post-search.md | 198 ++++++++++++++++-- lang/ar/posts.php | 3 + lang/en/posts.php | 3 + .../livewire/pages/posts/index.blade.php | 96 ++++++++- tests/Feature/Public/PostsTest.php | 152 ++++++++++++++ 6 files changed, 477 insertions(+), 27 deletions(-) create mode 100644 docs/qa/gates/5.5-post-search.yml diff --git a/docs/qa/gates/5.5-post-search.yml b/docs/qa/gates/5.5-post-search.yml new file mode 100644 index 0000000..b6b0dd1 --- /dev/null +++ b/docs/qa/gates/5.5-post-search.yml @@ -0,0 +1,52 @@ +# Quality Gate Decision +schema: 1 +story: "5.5" +story_title: "Post Search" +gate: PASS +status_reason: "All acceptance criteria met with comprehensive test coverage. Implementation follows coding standards with proper security measures (XSS protection, regex injection prevention)." +reviewer: "Quinn (Test Architect)" +updated: "2025-12-27T00:00:00Z" + +waiver: { active: false } + +top_issues: [] + +risk_summary: + totals: { critical: 0, high: 0, medium: 0, low: 0 } + recommendations: + must_fix: [] + monitor: + - action: "Monitor search performance at scale - LIKE on JSON columns may need optimization for large datasets" + refs: ["resources/views/livewire/pages/posts/index.blade.php:47-49"] + +quality_score: 100 +expires: "2026-01-10T00:00:00Z" + +evidence: + tests_reviewed: 9 + risks_identified: 0 + trace: + ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + ac_gaps: [] + +nfr_validation: + security: + status: PASS + notes: "XSS protection via e() escaping, regex injection prevented via preg_quote(), published-only scope" + performance: + status: PASS + notes: "300ms debounce, pagination at 10 items. LIKE on JSON acceptable for blog scale" + reliability: + status: PASS + notes: "Proper error handling, graceful empty states" + maintainability: + status: PASS + notes: "Clean code following Volt class-based pattern, well-structured tests" + +recommendations: + immediate: [] + future: + - action: "Consider full-text search or JSON path operators if post volume grows significantly" + refs: ["resources/views/livewire/pages/posts/index.blade.php:47-49"] + - action: "Search suggestions feature (marked optional in story)" + refs: ["docs/stories/story-5.5-post-search.md:35"] diff --git a/docs/stories/story-5.5-post-search.md b/docs/stories/story-5.5-post-search.md index 76868f9..af8f3f5 100644 --- a/docs/stories/story-5.5-post-search.md +++ b/docs/stories/story-5.5-post-search.md @@ -19,25 +19,25 @@ So that **I can find relevant legal articles and information**. ## Acceptance Criteria ### Search Functionality -- [ ] Search input on posts listing page -- [ ] Search by title (both languages) -- [ ] Search by body content (both languages) -- [ ] Real-time search results (debounced) +- [x] Search input on posts listing page +- [x] Search by title (both languages) +- [x] Search by body content (both languages) +- [x] Real-time search results (debounced) ### User Experience -- [ ] "No results found" message when empty -- [ ] Clear search button -- [ ] Search works in both Arabic and English -- [ ] Only searches published posts +- [x] "No results found" message when empty +- [x] Clear search button +- [x] Search works in both Arabic and English +- [x] Only searches published posts ### Optional Enhancements -- [ ] Search highlights in results +- [x] Search highlights in results - [ ] Search suggestions ### Quality Requirements -- [ ] Debounce to reduce server load (300ms) -- [ ] Case-insensitive search -- [ ] Tests for search functionality +- [x] Debounce to reduce server load (300ms) +- [x] Case-insensitive search +- [x] Tests for search functionality ## Technical Notes @@ -254,16 +254,48 @@ it('only searches published posts', function () { ## Definition of Done -- [ ] Search input on posts page -- [ ] Searches title in both languages -- [ ] Searches body in both languages -- [ ] Real-time results with debounce -- [ ] Clear search button works -- [ ] "No results" message shows -- [ ] Only published posts searched -- [ ] Search highlighting works -- [ ] Tests pass -- [ ] Code formatted with Pint +- [x] Search input on posts page +- [x] Searches title in both languages +- [x] Searches body in both languages +- [x] Real-time results with debounce +- [x] Clear search button works +- [x] "No results" message shows +- [x] Only published posts searched +- [x] Search highlighting works +- [x] Tests pass +- [x] Code formatted with Pint + +## Dev Agent Record + +### Agent Model Used +Claude Opus 4.5 + +### Completion Notes +- Implemented search functionality on the posts index page with real-time debounced search (300ms) +- Search queries both title and body fields as JSON columns (works with SQLite for tests and MySQL/MariaDB for production) +- Added search result highlighting with gold background on matched terms +- Clear search button with RTL/LTR position awareness +- Added bilingual translation strings for search UI (English and Arabic) +- 9 new search-specific tests added to PostsTest.php covering title search, body search, Arabic search, case-insensitivity, pagination reset, and clear functionality +- All 22 posts tests pass, full test suite passes + +### File List +- `resources/views/livewire/pages/posts/index.blade.php` (modified) - Added search functionality and highlight method +- `lang/en/posts.php` (modified) - Added search translation strings +- `lang/ar/posts.php` (modified) - Added Arabic search translation strings +- `tests/Feature/Public/PostsTest.php` (modified) - Added 9 search tests + +### Change Log +- Added `$search` property with debounced live binding +- Added `updatedSearch()` method to reset pagination on search change +- Added `clearSearch()` method to clear search and reset pagination +- Added `highlightSearch()` method for search term highlighting with HTML escaping +- Updated `with()` query to filter posts by title and body columns using LIKE +- Added search input with magnifying glass icon +- Added clear search button (X) with RTL-aware positioning +- Added search results info message showing count or "no results" message +- Added empty state with search icon and clear button when search yields no results +- Search highlighting applied to both title and excerpt in search results ## Dependencies @@ -273,3 +305,125 @@ it('only searches published posts', function () { **Complexity:** Low-Medium **Estimated Effort:** 2-3 hours + +--- + +## QA Results + +### Review Date: 2025-12-27 + +### Reviewed By: Quinn (Test Architect) + +### Code Quality Assessment + +The implementation is solid and follows project conventions well. The search functionality is implemented correctly using Livewire Volt's class-based pattern with proper debouncing, pagination reset, and bilingual support. The `highlightSearch()` method is well-designed with proper XSS protection using `e()` for both input text and search term before applying regex highlighting. + +**Notable positives:** +- Clean, readable code following coding standards +- Proper use of Flux UI components +- RTL-aware positioning for the clear search button +- Good separation between display logic (getTitle/getExcerpt) and search logic + +### Refactoring Performed + +None required - the implementation is clean and follows best practices. + +### Compliance Check + +- Coding Standards: ✓ Uses class-based Volt pattern, Flux UI components, proper testing +- Project Structure: ✓ Files in correct locations, naming follows conventions +- Testing Strategy: ✓ 9 comprehensive tests with 22 assertions covering all scenarios +- All ACs Met: ✓ All acceptance criteria verified with corresponding tests + +### Improvements Checklist + +- [x] Search functionality implemented with proper debouncing (300ms) +- [x] Bilingual search working for both title and body +- [x] XSS protection in highlight function +- [x] Case-insensitive search implemented +- [x] Clear search resets pagination +- [x] "No results" message displays correctly +- [x] Only published posts are searched +- [ ] Optional: Consider JSON path operators (`->`) for more precise JSON column searches in production MySQL (current LIKE on JSON works but is less precise) +- [ ] Optional enhancement: Search suggestions (marked as optional in story, not implemented) + +### Security Review + +✓ **XSS Protection**: The `highlightSearch()` method properly escapes HTML using `e()` before applying regex replacement, preventing XSS attacks. +✓ **Regex Injection**: `preg_quote()` is used to escape special regex characters in the search term. +✓ **Authorization**: Search is scoped to `published()` posts only - draft content is not accessible. + +### Performance Considerations + +✓ **Debouncing**: 300ms debounce reduces unnecessary server requests +✓ **Pagination**: 10 items per page limits data transfer +- Note: LIKE queries on JSON columns may become slow with very large datasets. For a blog with hundreds/thousands of posts, this is acceptable. For larger scale, consider full-text search indexes. + +### Files Modified During Review + +None - no refactoring was required. + +### Gate Status + +Gate: PASS → docs/qa/gates/5.5-post-search.yml + +### Recommended Status + +✓ Ready for Done - All acceptance criteria met, tests pass, code follows standards. + +--- + +## Story DoD Checklist + +### 1. Requirements Met: +- [x] All functional requirements specified in the story are implemented. + - Search input on posts listing page ✓ + - Search by title (both languages) ✓ + - Search by body content (both languages) ✓ + - Real-time search with 300ms debounce ✓ +- [x] All acceptance criteria defined in the story are met. + - "No results found" message ✓ + - Clear search button ✓ + - Works in Arabic and English ✓ + - Only searches published posts ✓ + - Search highlights in results ✓ + +### 2. Coding Standards & Project Structure: +- [x] All new/modified code strictly adheres to `Operational Guidelines`. +- [x] All new/modified code aligns with `Project Structure` (file locations, naming, etc.). +- [x] Adherence to `Tech Stack` for technologies/versions used. +- [N/A] Adherence to `Api Reference` and `Data Models` - No API or data model changes. +- [x] Basic security best practices applied (HTML escaping in highlight function). +- [x] No new linter errors or warnings introduced (Pint passes). +- [x] Code is well-commented where necessary. + +### 3. Testing: +- [x] All required unit tests implemented (9 new search tests). +- [N/A] Integration tests - Component tests cover functionality adequately. +- [x] All tests pass successfully (22 posts tests, full suite passes). +- [x] Test coverage meets project standards. + +### 4. Functionality & Verification: +- [x] Functionality verified through comprehensive test suite. +- [x] Edge cases handled (empty search, no results, case-insensitivity, RTL support). + +### 5. Story Administration: +- [x] All tasks within the story file are marked as complete. +- [x] Decisions documented (search on raw JSON columns vs JSON path, highlight implementation). +- [x] Story wrap up section completed with change log and file list. + +### 6. Dependencies, Build & Configuration: +- [x] Project builds successfully without errors. +- [x] Project linting passes (Pint --dirty). +- [N/A] No new dependencies added. +- [N/A] No new environment variables or configurations. + +### 7. Documentation: +- [x] Inline code documentation adequate (method names are self-documenting). +- [N/A] No user-facing documentation changes needed. +- [N/A] No architectural changes made. + +### Final Confirmation +- [x] I, the Developer Agent, confirm that all applicable items above have been addressed. + +**Status: Ready for Review** diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 62a5964..bd5de2c 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -7,6 +7,9 @@ return [ 'create_post' => 'إنشاء مقال', 'no_posts' => 'لا توجد مقالات.', 'search_placeholder' => 'البحث في المقالات...', + 'search_results' => 'تم العثور على :count نتيجة لـ ":query"', + 'no_results' => 'لم يتم العثور على نتائج لـ ":query"', + 'clear_search' => 'مسح البحث', 'last_updated' => 'آخر تحديث', 'created' => 'تاريخ الإنشاء', 'read_more' => 'اقرأ المزيد', diff --git a/lang/en/posts.php b/lang/en/posts.php index e82a3b5..348dcbc 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -7,6 +7,9 @@ return [ 'create_post' => 'Create Post', 'no_posts' => 'No posts found.', 'search_placeholder' => 'Search posts...', + 'search_results' => 'Found :count results for ":query"', + 'no_results' => 'No results found for ":query"', + 'clear_search' => 'Clear search', 'last_updated' => 'Last Updated', 'created' => 'Created', 'read_more' => 'Read More', diff --git a/resources/views/livewire/pages/posts/index.blade.php b/resources/views/livewire/pages/posts/index.blade.php index af29a0e..fead8c7 100644 --- a/resources/views/livewire/pages/posts/index.blade.php +++ b/resources/views/livewire/pages/posts/index.blade.php @@ -9,10 +9,46 @@ new #[Layout('components.layouts.public')] class extends Component { use WithPagination; + public string $search = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function clearSearch(): void + { + $this->search = ''; + $this->resetPage(); + } + + public function highlightSearch(string $text, string $search): string + { + if (empty($search)) { + return e($text); + } + + $escapedText = e($text); + $escapedSearch = e($search); + + return preg_replace( + '/('.preg_quote($escapedSearch, '/').')/iu', + '$1', + $escapedText + ); + } + public function with(): array { return [ 'posts' => Post::published() + ->when($this->search, function ($query) { + $search = $this->search; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('body', 'like', "%{$search}%"); + }); + }) ->latest() ->paginate(10), ]; @@ -22,12 +58,50 @@ new #[Layout('components.layouts.public')] class extends Component
{{ __('posts.posts') }} + +
+ + + + + + + @if($search) + + @endif +
+ + + @if($search) +

+ @if($posts->total() > 0) + {{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }} + @else + {{ __('posts.no_results', ['query' => $search]) }} + @endif +

+ @endif + +
@forelse($posts as $post) @empty -
- -

{{ __('posts.no_posts') }}

+
+ @if($search) + +

{{ __('posts.no_results', ['query' => $search]) }}

+ + {{ __('posts.clear_search') }} + + @else + +

{{ __('posts.no_posts') }}

+ @endif
@endforelse
diff --git a/tests/Feature/Public/PostsTest.php b/tests/Feature/Public/PostsTest.php index 5aa1c0a..b64743e 100644 --- a/tests/Feature/Public/PostsTest.php +++ b/tests/Feature/Public/PostsTest.php @@ -153,3 +153,155 @@ test('individual post page displays content in current locale', function () { ->assertSee('عنوان عربي') ->assertSee('محتوى عربي'); }); + +// Search functionality tests + +test('search input is displayed on posts listing page', function () { + $this->withSession(['locale' => 'en']) + ->get(route('posts.index')) + ->assertOk() + ->assertSee('Search posts...'); +}); + +test('search filters posts by English title', function () { + Post::factory()->published()->create([ + 'title' => ['en' => 'Legal Rights Guide', 'ar' => 'دليل الحقوق القانونية'], + ]); + Post::factory()->published()->create([ + 'title' => ['en' => 'Tax Information', 'ar' => 'معلومات ضريبية'], + ]); + + app()->setLocale('en'); + + // Title is highlighted, so we check for parts that won't be in the mark tag + // "Legal" will be in , "Rights Guide" will be after + Livewire\Volt\Volt::test('pages.posts.index') + ->set('search', 'Legal') + ->assertSee('Rights Guide') // Part after the highlighted word + ->assertSee('Legal') // The highlighted word (inside ) + ->assertDontSee('Tax Information'); +}); + +test('search filters posts by Arabic title', function () { + Post::factory()->published()->create([ + 'title' => ['en' => 'Legal Rights Guide', 'ar' => 'دليل الحقوق القانونية'], + ]); + Post::factory()->published()->create([ + 'title' => ['en' => 'Tax Information', 'ar' => 'معلومات ضريبية'], + ]); + + app()->setLocale('ar'); + + // Search for "الحقوق" (rights) - should find first post only + $component = Livewire\Volt\Volt::test('pages.posts.index') + ->set('search', 'الحقوق'); + + // Check we have results (not "no results") + $html = $component->html(); + expect(str_contains($html, 'تم العثور'))->toBeTrue('Should show found results message'); + + // Check the highlighted word appears + $component->assertSee('الحقوق') // The highlighted word + ->assertDontSee('ضريبية'); // Part of second post +}); + +test('search filters posts by body content', function () { + Post::factory()->published()->create([ + 'title' => ['en' => 'First Article', 'ar' => 'المقال الأول'], + 'body' => ['en' => 'This discusses property law topics.', 'ar' => 'هذا يناقش مواضيع قانون الملكية.'], + ]); + Post::factory()->published()->create([ + 'title' => ['en' => 'Second Article', 'ar' => 'المقال الثاني'], + 'body' => ['en' => 'This is about family matters.', 'ar' => 'هذا يتعلق بشؤون الأسرة.'], + ]); + + app()->setLocale('en'); + + // Searching body content - title doesn't contain 'property' so won't be highlighted + Livewire\Volt\Volt::test('pages.posts.index') + ->set('search', 'property') + ->assertSee('First Article') // This title has no highlight since match is in body + ->assertDontSee('Second Article'); +}); + +test('search shows no results message when no matches found', function () { + Post::factory()->published()->create([ + 'title' => ['en' => 'Test Post', 'ar' => 'مقال اختبار'], + ]); + + app()->setLocale('en'); + + Livewire\Volt\Volt::test('pages.posts.index') + ->set('search', 'nonexistent') + ->assertSee('No results found'); +}); + +test('search only searches published posts', function () { + Post::factory()->draft()->create([ + 'title' => ['en' => 'Draft Article', 'ar' => 'مقال مسودة'], + ]); + Post::factory()->published()->create([ + 'title' => ['en' => 'Published Article', 'ar' => 'مقال منشور'], + ]); + + app()->setLocale('en'); + + // Search for "Article" - both have it in title, but only published should appear + // "Article" will be highlighted in "Published Article" + Livewire\Volt\Volt::test('pages.posts.index') + ->set('search', 'Article') + ->assertSee('Published') // Part of the title without highlight + ->assertSee('Article') // The highlighted word + ->assertDontSee('Draft'); +}); + +test('clear search button resets search and shows all posts', function () { + Post::factory()->published()->create([ + 'title' => ['en' => 'Alpha Article', 'ar' => 'المقال الأول'], + ]); + Post::factory()->published()->create([ + 'title' => ['en' => 'Beta Article', 'ar' => 'المقال الثاني'], + ]); + + app()->setLocale('en'); + + Livewire\Volt\Volt::test('pages.posts.index') + ->set('search', 'Alpha') + ->assertSee('Alpha') // Highlighted in "Alpha Article" + ->assertDontSee('Beta') + ->call('clearSearch') + ->assertSet('search', '') + ->assertSee('Alpha Article') // No highlighting after clear + ->assertSee('Beta Article'); +}); + +test('search is case insensitive', function () { + Post::factory()->published()->create([ + 'title' => ['en' => 'UPPERCASE TITLE', 'ar' => 'عنوان كبير'], + ]); + + app()->setLocale('en'); + + // Search lowercase "uppercase" should find "UPPERCASE TITLE" + // Note: "uppercase" will be highlighted (case preserved in original) + Livewire\Volt\Volt::test('pages.posts.index') + ->set('search', 'uppercase') + ->assertSee('UPPERCASE') // The highlighted word + ->assertSee('TITLE'); // Rest of title +}); + +test('search resets pagination', function () { + Post::factory()->count(15)->published()->create(); + Post::factory()->published()->create([ + 'title' => ['en' => 'Special Searchable Post', 'ar' => 'مقال قابل للبحث خاص'], + ]); + + $component = Livewire\Volt\Volt::test('pages.posts.index'); + + // Navigate to page 2 + $component->call('gotoPage', 2); + + // Set search - should reset to page 1 + $component->set('search', 'Special') + ->assertSet('paginators.page', 1); +});