reviewed epic 5 & 6 stories
This commit is contained in:
@@ -49,6 +49,22 @@ So that **I can publish professional legal content for website visitors**.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### HTML Sanitization
|
||||
Use the `mews/purifier` package for HTML sanitization via the `clean()` helper:
|
||||
```bash
|
||||
composer require mews/purifier
|
||||
php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider"
|
||||
```
|
||||
|
||||
Configure `config/purifier.php` to allow only safe rich text tags:
|
||||
```php
|
||||
'default' => [
|
||||
'HTML.Allowed' => 'h2,h3,p,br,b,strong,i,em,u,ul,ol,li,a[href|title],blockquote',
|
||||
'AutoFormat.AutoParagraph' => true,
|
||||
'AutoFormat.RemoveEmpty' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
```php
|
||||
Schema::create('posts', function (Blueprint $table) {
|
||||
@@ -147,7 +163,8 @@ new class extends Component {
|
||||
$action = 'create';
|
||||
}
|
||||
|
||||
AdminLog::create([
|
||||
// AdminLog model exists from Epic 1 (Story 1.1) - see admin_logs table in schema
|
||||
\App\Models\AdminLog::create([
|
||||
'admin_id' => auth()->id(),
|
||||
'action_type' => $action,
|
||||
'target_type' => 'post',
|
||||
@@ -181,12 +198,26 @@ new class extends Component {
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preview opens modal with sanitized rendered content
|
||||
public bool $showPreview = false;
|
||||
|
||||
public function preview(): void
|
||||
{
|
||||
$this->showPreview = true;
|
||||
}
|
||||
|
||||
public function closePreview(): void
|
||||
{
|
||||
$this->showPreview = false;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Template with Rich Text Editor
|
||||
```blade
|
||||
<div>
|
||||
{{-- wire:poll.60s triggers autoSave every 60 seconds for draft posts --}}
|
||||
<div wire:poll.60s="autoSave">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Arabic Fields -->
|
||||
<div class="space-y-4">
|
||||
@@ -238,13 +269,44 @@ new class extends Component {
|
||||
<flux:button wire:click="saveDraft">
|
||||
{{ __('admin.save_draft') }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="preview">
|
||||
{{ __('admin.preview') }}
|
||||
</flux:button>
|
||||
<flux:button variant="primary" wire:click="publish">
|
||||
{{ __('admin.publish') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Preview Modal --}}
|
||||
<flux:modal wire:model="showPreview" class="max-w-4xl">
|
||||
<flux:heading>{{ __('admin.preview') }}</flux:heading>
|
||||
<div class="mt-4 space-y-6">
|
||||
<div class="border-b pb-4">
|
||||
<h3 class="font-semibold text-lg mb-2">{{ __('admin.arabic_content') }}</h3>
|
||||
<h2 class="text-xl font-bold" dir="rtl">{{ $title_ar }}</h2>
|
||||
<div class="prose mt-2" dir="rtl">{!! clean($body_ar) !!}</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-2">{{ __('admin.english_content') }}</h3>
|
||||
<h2 class="text-xl font-bold">{{ $title_en }}</h2>
|
||||
<div class="prose mt-2">{!! clean($body_en) !!}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<flux:button wire:click="closePreview">{{ __('admin.close') }}</flux:button>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Trix Editor Setup
|
||||
Include Trix editor assets in your layout or component:
|
||||
```blade
|
||||
{{-- In your layout head --}}
|
||||
<link rel="stylesheet" href="https://unpkg.com/trix@2.0.0/dist/trix.css">
|
||||
<script src="https://unpkg.com/trix@2.0.0/dist/trix.umd.min.js"></script>
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Can create post with bilingual content
|
||||
@@ -253,11 +315,164 @@ new class extends Component {
|
||||
- [ ] Can publish directly
|
||||
- [ ] Can edit existing posts
|
||||
- [ ] Auto-save works for drafts
|
||||
- [ ] Preview modal displays sanitized content
|
||||
- [ ] HTML properly sanitized
|
||||
- [ ] Audit log created
|
||||
- [ ] Tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Unit Tests (Post Model)
|
||||
```php
|
||||
test('post has bilingual title accessor', function () {
|
||||
$post = Post::factory()->create(['title_ar' => 'عنوان', 'title_en' => 'Title']);
|
||||
app()->setLocale('ar');
|
||||
expect($post->title)->toBe('عنوان');
|
||||
app()->setLocale('en');
|
||||
expect($post->title)->toBe('Title');
|
||||
});
|
||||
|
||||
test('post excerpt strips HTML and limits to 150 chars', function () {
|
||||
$post = Post::factory()->create(['body_en' => '<p>' . str_repeat('a', 200) . '</p>']);
|
||||
expect(strlen($post->excerpt))->toBeLessThanOrEqual(153); // 150 + '...'
|
||||
expect($post->excerpt)->not->toContain('<p>');
|
||||
});
|
||||
|
||||
test('published scope returns only published posts', function () {
|
||||
Post::factory()->create(['status' => 'draft']);
|
||||
Post::factory()->create(['status' => 'published']);
|
||||
expect(Post::published()->count())->toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Feature Tests (Volt Component)
|
||||
```php
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('admin can create post with valid bilingual content', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.posts.create')
|
||||
->actingAs($admin)
|
||||
->set('title_ar', 'عنوان المقال')
|
||||
->set('title_en', 'Article Title')
|
||||
->set('body_ar', '<p>محتوى المقال</p>')
|
||||
->set('body_en', '<p>Article content</p>')
|
||||
->call('saveDraft')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Post::where('title_en', 'Article Title')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('create post fails with missing required fields', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.posts.create')
|
||||
->actingAs($admin)
|
||||
->set('title_ar', '')
|
||||
->set('title_en', 'Title')
|
||||
->call('save')
|
||||
->assertHasErrors(['title_ar']);
|
||||
});
|
||||
|
||||
test('save draft preserves draft status', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.posts.create')
|
||||
->actingAs($admin)
|
||||
->set('title_ar', 'عنوان')
|
||||
->set('title_en', 'Title')
|
||||
->set('body_ar', 'محتوى')
|
||||
->set('body_en', 'Content')
|
||||
->call('saveDraft');
|
||||
|
||||
expect(Post::first()->status)->toBe('draft');
|
||||
});
|
||||
|
||||
test('publish changes status to published', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.posts.create')
|
||||
->actingAs($admin)
|
||||
->set('title_ar', 'عنوان')
|
||||
->set('title_en', 'Title')
|
||||
->set('body_ar', 'محتوى')
|
||||
->set('body_en', 'Content')
|
||||
->call('publish');
|
||||
|
||||
expect(Post::first()->status)->toBe('published');
|
||||
});
|
||||
|
||||
test('edit existing post updates content', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$post = Post::factory()->create(['title_en' => 'Original']);
|
||||
|
||||
Volt::test('admin.posts.edit', ['post' => $post])
|
||||
->actingAs($admin)
|
||||
->set('title_en', 'Updated')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($post->fresh()->title_en)->toBe('Updated');
|
||||
});
|
||||
|
||||
test('auto-save only fires for draft posts', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$post = Post::factory()->create(['status' => 'published', 'title_en' => 'Original']);
|
||||
|
||||
Volt::test('admin.posts.edit', ['post' => $post])
|
||||
->actingAs($admin)
|
||||
->set('title_en', 'Changed')
|
||||
->call('autoSave');
|
||||
|
||||
// Published post should NOT be auto-saved
|
||||
expect($post->fresh()->title_en)->toBe('Original');
|
||||
});
|
||||
|
||||
test('HTML sanitization removes script tags but keeps allowed formatting', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.posts.create')
|
||||
->actingAs($admin)
|
||||
->set('title_ar', 'عنوان')
|
||||
->set('title_en', 'Title')
|
||||
->set('body_ar', '<p>نص</p>')
|
||||
->set('body_en', '<p>Safe</p><script>alert("xss")</script><strong>Bold</strong>')
|
||||
->call('saveDraft');
|
||||
|
||||
$post = Post::first();
|
||||
expect($post->body_en)->not->toContain('<script>');
|
||||
expect($post->body_en)->toContain('<strong>Bold</strong>');
|
||||
});
|
||||
|
||||
test('admin log created on post create', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.posts.create')
|
||||
->actingAs($admin)
|
||||
->set('title_ar', 'عنوان')
|
||||
->set('title_en', 'Title')
|
||||
->set('body_ar', 'محتوى')
|
||||
->set('body_en', 'Content')
|
||||
->call('saveDraft');
|
||||
|
||||
expect(AdminLog::where('action_type', 'create')->where('target_type', 'post')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('preview modal can be opened and closed', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.posts.create')
|
||||
->actingAs($admin)
|
||||
->assertSet('showPreview', false)
|
||||
->call('preview')
|
||||
->assertSet('showPreview', true)
|
||||
->call('closePreview')
|
||||
->assertSet('showPreview', false);
|
||||
});
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Epic 1:** Database schema, admin authentication
|
||||
|
||||
Reference in New Issue
Block a user