diff --git a/app/Http/Controllers/Api/EmojiController.php b/app/Http/Controllers/Api/EmojiController.php index 9357208f2..2dda8308c 100644 --- a/app/Http/Controllers/Api/EmojiController.php +++ b/app/Http/Controllers/Api/EmojiController.php @@ -100,7 +100,7 @@ class EmojiController extends Controller public function search(SearchEmojiRequest $request) { $emojis = Emoji::search($request->get('q')) - ->take($request->get('limit', 20)) + ->take((int) $request->get('limit', 20)) ->get(); // Load relationships if requested diff --git a/app/Http/Controllers/Api/WordFilterController.php b/app/Http/Controllers/Api/WordFilterController.php new file mode 100644 index 000000000..f10fff5ef --- /dev/null +++ b/app/Http/Controllers/Api/WordFilterController.php @@ -0,0 +1,131 @@ +when($request->get('filter_type'), function ($query, $filterType) { + $query->where('filter_type', $filterType); + }) + ->when($request->get('pattern_type'), function ($query, $patternType) { + $query->where('pattern_type', $patternType); + }) + ->when($request->get('severity'), function ($query, $severity) { + $query->where('severity', $severity); + }) + ->when($request->has('is_active'), function ($query) use ($request) { + $query->where('is_active', $request->boolean('is_active')); + }) + ->when($request->get('applies_to'), function ($query, $appliesTo) { + $query->whereJsonContains('applies_to', $appliesTo); + }) + ->when($request->get('search'), function ($query, $search) { + $query->where(function ($q) use ($search) { + $q->where('pattern', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%"); + }); + }) + ->when($request->get('with_creator'), function ($query) { + $query->with('creator'); + }) + ->orderBy($request->get('sort_by', 'created_at'), $request->get('sort_order', 'desc')) + ->paginate($request->get('per_page', 50)); + + return WordFilterResource::collection($filters); + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreWordFilterRequest $request) + { + $filter = WordFilter::create($request->validated()); + + if ($request->get('with_creator')) { + $filter->load('creator'); + } + + return new WordFilterResource($filter); + } + + /** + * Display the specified resource. + */ + public function show(WordFilter $filter, Request $request) + { + if ($request->get('with_creator')) { + $filter->load('creator'); + } + + return new WordFilterResource($filter); + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateWordFilterRequest $request, WordFilter $filter) + { + $filter->update($request->validated()); + + if ($request->get('with_creator')) { + $filter->load('creator'); + } + + return new WordFilterResource($filter); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(WordFilter $filter) + { + $filter->delete(); + + return response()->json(null, 204); + } + + /** + * Search word filters using Laravel Scout. + */ + public function search(SearchWordFilterRequest $request) + { + $filters = WordFilter::search($request->get('q')) + ->take((int) $request->get('limit', 20)) + ->get(); + + // Apply additional filters after search + if ($request->get('filter_type')) { + $filters = $filters->where('filter_type', $request->get('filter_type')); + } + + if ($request->get('severity')) { + $filters = $filters->where('severity', $request->get('severity')); + } + + if ($request->has('is_active')) { + $filters = $filters->where('is_active', $request->boolean('is_active')); + } + + // Load relationships if requested + if ($request->get('with_creator')) { + $filters->load('creator'); + } + + return WordFilterResource::collection($filters); + } +} diff --git a/app/Http/Controllers/WordFilter/WordFilterController.php b/app/Http/Controllers/WordFilter/WordFilterController.php new file mode 100644 index 000000000..f30d53481 --- /dev/null +++ b/app/Http/Controllers/WordFilter/WordFilterController.php @@ -0,0 +1,67 @@ +|string> + */ + public function rules(): array + { + return [ + 'filter_type' => ['sometimes', 'string', Rule::in(WordFilter::getFilterTypes())], + 'pattern_type' => ['sometimes', 'string', Rule::in(WordFilter::getPatternTypes())], + 'severity' => ['sometimes', 'string', Rule::in(WordFilter::getSeverityLevels())], + 'is_active' => 'sometimes|boolean', + 'applies_to' => ['sometimes', 'string', Rule::in(WordFilter::getContentTypes())], + 'search' => 'sometimes|string|max:100', + 'with_creator' => 'sometimes|boolean', + 'sort_by' => ['sometimes', 'string', Rule::in(['created_at', 'updated_at', 'pattern', 'severity'])], + 'sort_order' => ['sometimes', 'string', Rule::in(['asc', 'desc'])], + 'page' => 'sometimes|integer|min:1', + 'per_page' => 'sometimes|integer|min:1|max:100', + ]; + } +} diff --git a/app/Http/Requests/WordFilter/SearchWordFilterRequest.php b/app/Http/Requests/WordFilter/SearchWordFilterRequest.php new file mode 100644 index 000000000..fc420731d --- /dev/null +++ b/app/Http/Requests/WordFilter/SearchWordFilterRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'q' => 'required|string|min:1|max:100', + 'limit' => 'sometimes|integer|min:1|max:100', + 'filter_type' => ['sometimes', 'string', Rule::in(WordFilter::getFilterTypes())], + 'severity' => ['sometimes', 'string', Rule::in(WordFilter::getSeverityLevels())], + 'is_active' => 'sometimes|boolean', + 'with_creator' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/WordFilter/StoreWordFilterRequest.php b/app/Http/Requests/WordFilter/StoreWordFilterRequest.php new file mode 100644 index 000000000..fc88737e4 --- /dev/null +++ b/app/Http/Requests/WordFilter/StoreWordFilterRequest.php @@ -0,0 +1,79 @@ +|string> + */ + public function rules(): array + { + return [ + 'pattern' => ['required', 'string', 'max:255', $this->getPatternValidation()], + 'replacement' => ['nullable', 'string', 'max:255', $this->getReplacementValidation()], + 'filter_type' => ['required', 'string', Rule::in(WordFilter::getFilterTypes())], + 'pattern_type' => ['required', 'string', Rule::in(WordFilter::getPatternTypes())], + 'severity' => ['required', 'string', Rule::in(WordFilter::getSeverityLevels())], + 'is_active' => 'sometimes|boolean', + 'case_sensitive' => 'sometimes|boolean', + 'applies_to' => 'required|array|min:1', + 'applies_to.*' => ['required', 'string', Rule::in(WordFilter::getContentTypes())], + 'created_by' => 'sometimes|nullable|exists:users,id', + 'notes' => 'nullable|string|max:1000', + ]; + } + + /** + * Get the pattern validation rule based on pattern type. + */ + protected function getPatternValidation(): ?ValidRegexRule + { + if ($this->input('pattern_type') === WordFilter::PATTERN_TYPE_REGEX) { + return new ValidRegexRule; + } + + return null; + } + + /** + * Get the replacement validation rule based on filter type. + */ + protected function getReplacementValidation(): ?string + { + if ($this->input('filter_type') === WordFilter::FILTER_TYPE_REPLACE) { + return 'required'; + } + + return null; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'replacement.required' => 'The replacement field is required when filter type is replace.', + 'applies_to.required' => 'At least one content type must be selected.', + 'applies_to.*.in' => 'Invalid content type selected.', + ]; + } +} diff --git a/app/Http/Requests/WordFilter/UpdateWordFilterRequest.php b/app/Http/Requests/WordFilter/UpdateWordFilterRequest.php new file mode 100644 index 000000000..39da8ba4b --- /dev/null +++ b/app/Http/Requests/WordFilter/UpdateWordFilterRequest.php @@ -0,0 +1,91 @@ +|string> + */ + public function rules(): array + { + return [ + 'pattern' => ['sometimes', 'string', 'max:255', $this->getPatternValidation()], + 'replacement' => $this->getReplacementRules(), + 'filter_type' => ['sometimes', 'string', Rule::in(WordFilter::getFilterTypes())], + 'pattern_type' => ['sometimes', 'string', Rule::in(WordFilter::getPatternTypes())], + 'severity' => ['sometimes', 'string', Rule::in(WordFilter::getSeverityLevels())], + 'is_active' => 'sometimes|boolean', + 'case_sensitive' => 'sometimes|boolean', + 'applies_to' => 'sometimes|array|min:1', + 'applies_to.*' => ['required', 'string', Rule::in(WordFilter::getContentTypes())], + 'created_by' => 'sometimes|nullable|exists:users,id', + 'notes' => 'sometimes|nullable|string|max:1000', + ]; + } + + /** + * Get the pattern validation rule based on pattern type. + */ + protected function getPatternValidation(): ?ValidRegexRule + { + $patternType = $this->input('pattern_type') ?? $this->route('filter')->pattern_type; + + if ($patternType === WordFilter::PATTERN_TYPE_REGEX) { + return new ValidRegexRule; + } + + return null; + } + + /** + * Get the replacement field validation rules. + */ + protected function getReplacementRules(): array + { + $filterType = $this->input('filter_type') ?? $this->route('filter')->filter_type; + + // If the filter type is or will be 'replace' + if ($filterType === WordFilter::FILTER_TYPE_REPLACE) { + // If we're changing to replace type, replacement must be provided + if ($this->has('filter_type') && !$this->filled('replacement')) { + return ['required', 'string', 'max:255']; + } + + // Otherwise, validate only if provided + return ['sometimes', 'required', 'string', 'max:255']; + } + + // For non-replace types + return ['sometimes', 'nullable', 'string', 'max:255']; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'replacement.required' => 'The replacement field is required when filter type is replace.', + 'applies_to.required' => 'At least one content type must be selected.', + 'applies_to.*.in' => 'Invalid content type selected.', + ]; + } +} diff --git a/app/Http/Resources/WordFilterResource.php b/app/Http/Resources/WordFilterResource.php new file mode 100644 index 000000000..8888fc3d3 --- /dev/null +++ b/app/Http/Resources/WordFilterResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'pattern' => $this->pattern, + 'replacement' => $this->replacement, + 'filter_type' => $this->filter_type, + 'pattern_type' => $this->pattern_type, + 'severity' => $this->severity, + 'is_active' => $this->is_active, + 'case_sensitive' => $this->case_sensitive, + 'applies_to' => $this->applies_to, + 'notes' => $this->notes, + 'creator' => $this->whenLoaded('creator', function () { + return $this->creator ? [ + 'id' => $this->creator->id, + 'name' => $this->creator->name, + 'email' => $this->creator->email, + ] : null; + }, null), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/WordFilter.php b/app/Models/WordFilter.php new file mode 100644 index 000000000..2cd490f38 --- /dev/null +++ b/app/Models/WordFilter.php @@ -0,0 +1,167 @@ + */ + use HasFactory, Searchable; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'pattern', + 'replacement', + 'filter_type', + 'pattern_type', + 'severity', + 'is_active', + 'case_sensitive', + 'applies_to', + 'created_by', + 'notes', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'is_active' => 'boolean', + 'case_sensitive' => 'boolean', + 'applies_to' => 'array', + ]; + + /** + * Filter type constants. + */ + public const string FILTER_TYPE_REPLACE = 'replace'; + + public const string FILTER_TYPE_BLOCK = 'block'; + + public const string FILTER_TYPE_MODERATE = 'moderate'; + + /** + * Pattern type constants. + */ + public const string PATTERN_TYPE_EXACT = 'exact'; + + public const string PATTERN_TYPE_WILDCARD = 'wildcard'; + + public const string PATTERN_TYPE_REGEX = 'regex'; + + /** + * Severity constants. + */ + public const string SEVERITY_LOW = 'low'; + + public const string SEVERITY_MEDIUM = 'medium'; + + public const string SEVERITY_HIGH = 'high'; + + /** + * Content type constants for applies_to field. + */ + public const string APPLIES_TO_POSTS = 'posts'; + + public const string APPLIES_TO_PRIVATE_MESSAGES = 'private_messages'; + + public const string APPLIES_TO_USERNAMES = 'usernames'; + + public const string APPLIES_TO_SIGNATURES = 'signatures'; + + public const string APPLIES_TO_PROFILE_FIELDS = 'profile_fields'; + + /** + * Get all available filter types. + * + * @return array + */ + public static function getFilterTypes(): array + { + return [ + self::FILTER_TYPE_REPLACE, + self::FILTER_TYPE_BLOCK, + self::FILTER_TYPE_MODERATE, + ]; + } + + /** + * Get all available pattern types. + * + * @return array + */ + public static function getPatternTypes(): array + { + return [ + self::PATTERN_TYPE_EXACT, + self::PATTERN_TYPE_WILDCARD, + self::PATTERN_TYPE_REGEX, + ]; + } + + /** + * Get all available severity levels. + * + * @return array + */ + public static function getSeverityLevels(): array + { + return [ + self::SEVERITY_LOW, + self::SEVERITY_MEDIUM, + self::SEVERITY_HIGH, + ]; + } + + /** + * Get all available content types. + * + * @return array + */ + public static function getContentTypes(): array + { + return [ + self::APPLIES_TO_POSTS, + self::APPLIES_TO_PRIVATE_MESSAGES, + self::APPLIES_TO_USERNAMES, + self::APPLIES_TO_SIGNATURES, + self::APPLIES_TO_PROFILE_FIELDS, + ]; + } + + /** + * Get the user who created the filter. + * + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Get the indexable data array for the model. + * + * @return array + */ + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'pattern' => $this->pattern, + 'notes' => $this->notes, + 'filter_type' => $this->filter_type, + 'severity' => $this->severity, + ]; + } +} diff --git a/app/Policies/WordFilterPolicy.php b/app/Policies/WordFilterPolicy.php new file mode 100644 index 000000000..b18c53a60 --- /dev/null +++ b/app/Policies/WordFilterPolicy.php @@ -0,0 +1,65 @@ + + */ +class WordFilterFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $filterType = $this->faker->randomElement(WordFilter::getFilterTypes()); + $patternType = $this->faker->randomElement(WordFilter::getPatternTypes()); + + // Generate appropriate pattern based on type + $pattern = match ($patternType) { + 'exact' => $this->faker->word(), + 'wildcard' => '*' . $this->faker->word() . '*', + 'regex' => '/\\b' . $this->faker->word() . '\\b/i', + }; + + return [ + 'pattern' => $pattern, + 'replacement' => $filterType === 'replace' ? str_repeat('*', strlen($this->faker->word())) : null, + 'filter_type' => $filterType, + 'pattern_type' => $patternType, + 'severity' => $this->faker->randomElement(WordFilter::getSeverityLevels()), + 'is_active' => $this->faker->boolean(80), // 80% chance of being active + 'case_sensitive' => $this->faker->boolean(20), // 20% chance of being case-sensitive + 'applies_to' => $this->faker->randomElements( + WordFilter::getContentTypes(), + $this->faker->numberBetween(1, 3) + ), + 'created_by' => null, + 'notes' => $this->faker->boolean(50) ? $this->faker->sentence() : null, + ]; + } +} diff --git a/database/migrations/2025_07_04_231100_create_word_filters_table.php b/database/migrations/2025_07_04_231100_create_word_filters_table.php new file mode 100644 index 000000000..39cda8117 --- /dev/null +++ b/database/migrations/2025_07_04_231100_create_word_filters_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('pattern'); + $table->string('replacement')->nullable(); + $table->enum('filter_type', ['replace', 'block', 'moderate']); + $table->enum('pattern_type', ['exact', 'wildcard', 'regex']); + $table->enum('severity', ['low', 'medium', 'high']); + $table->boolean('is_active')->default(true); + $table->boolean('case_sensitive')->default(false); + $table->json('applies_to'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + + // Indexes for performance + $table->index('pattern'); + $table->index('filter_type'); + $table->index('is_active'); + $table->index('severity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('word_filters'); + } +}; diff --git a/database/seeders/WordFilterSeeder.php b/database/seeders/WordFilterSeeder.php new file mode 100644 index 000000000..d781a16e6 --- /dev/null +++ b/database/seeders/WordFilterSeeder.php @@ -0,0 +1,16 @@ + + diff --git a/routes/api.php b/routes/api.php index 618a824b0..8a0c35b83 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\EmojiAliasController; use App\Http\Controllers\Api\EmojiCategoryController; use App\Http\Controllers\Api\EmojiController; +use App\Http\Controllers\Api\WordFilterController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -23,3 +24,10 @@ Route::prefix('emoji')->group(function () { // Emoji Categories Route::apiResource('categories', EmojiCategoryController::class); }); + +// Word Filter API Routes +Route::prefix('word-filters')->group(function () { + // Search route must come before resource routes + Route::get('search', [WordFilterController::class, 'search'])->name('word-filters.search'); + Route::apiResource('/', WordFilterController::class)->parameters(['' => 'filter']); +}); diff --git a/tests/Feature/Api/EmojiAliasTest.php b/tests/Feature/Api/EmojiAliasTest.php index 24c6f7a8d..31aff54a1 100644 --- a/tests/Feature/Api/EmojiAliasTest.php +++ b/tests/Feature/Api/EmojiAliasTest.php @@ -4,6 +4,10 @@ use App\Models\Emoji; use App\Models\EmojiAlias; use App\Models\EmojiCategory; +beforeEach(function () { + config(['scout.driver' => 'collection']); +}); + describe('Emoji Alias API Endpoints', function () { test('can list aliases', function () { $category = EmojiCategory::factory()->create(); diff --git a/tests/Feature/Api/EmojiTest.php b/tests/Feature/Api/EmojiTest.php index 739a28bcb..49b82f3b0 100644 --- a/tests/Feature/Api/EmojiTest.php +++ b/tests/Feature/Api/EmojiTest.php @@ -4,6 +4,10 @@ use App\Models\Emoji; use App\Models\EmojiAlias; use App\Models\EmojiCategory; +beforeEach(function () { + config(['scout.driver' => 'collection']); +}); + describe('Emoji API Endpoints', function () { test('can list emojis', function () { $category = EmojiCategory::factory()->create(); diff --git a/tests/Feature/Api/WordFilterTest.php b/tests/Feature/Api/WordFilterTest.php new file mode 100644 index 000000000..bf577d19c --- /dev/null +++ b/tests/Feature/Api/WordFilterTest.php @@ -0,0 +1,392 @@ + 'collection']); +}); + +describe('Word Filter API', function () { + describe('Index', function () { + test('can list word filters', function () { + WordFilter::factory()->count(3)->create(); + + $response = $this->getJson('/api/word-filters'); + + $response->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'pattern', + 'replacement', + 'filter_type', + 'pattern_type', + 'severity', + 'is_active', + 'case_sensitive', + 'applies_to', + 'notes', + 'creator', + 'created_at', + 'updated_at', + ], + ], + 'links', + 'meta', + ]); + }); + + test('can filter by filter type', function () { + WordFilter::factory()->create(['filter_type' => 'replace']); + WordFilter::factory()->create(['filter_type' => 'block']); + WordFilter::factory()->create(['filter_type' => 'moderate']); + + $response = $this->getJson('/api/word-filters?filter_type=block'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.filter_type', 'block'); + }); + + test('can filter by pattern type', function () { + WordFilter::factory()->create(['pattern_type' => 'exact']); + WordFilter::factory()->create(['pattern_type' => 'wildcard']); + WordFilter::factory()->create(['pattern_type' => 'regex']); + + $response = $this->getJson('/api/word-filters?pattern_type=wildcard'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.pattern_type', 'wildcard'); + }); + + test('can filter by severity', function () { + WordFilter::factory()->create(['severity' => 'low']); + WordFilter::factory()->create(['severity' => 'medium']); + WordFilter::factory()->create(['severity' => 'high']); + + $response = $this->getJson('/api/word-filters?severity=high'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.severity', 'high'); + }); + + test('can filter by active status', function () { + WordFilter::factory()->create(['is_active' => true]); + WordFilter::factory()->create(['is_active' => false]); + + $response = $this->getJson('/api/word-filters?is_active=0'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.is_active', false); + }); + + test('can filter by applies to', function () { + WordFilter::factory()->create(['applies_to' => ['posts', 'private_messages']]); + WordFilter::factory()->create(['applies_to' => ['usernames']]); + + $response = $this->getJson('/api/word-filters?applies_to=usernames'); + + $response->assertOk() + ->assertJsonCount(1, 'data'); + }); + + test('can search by pattern and notes', function () { + WordFilter::factory()->create(['pattern' => 'badword', 'notes' => 'common profanity']); + WordFilter::factory()->create(['pattern' => 'spam', 'notes' => 'commercial spam']); + + $response = $this->getJson('/api/word-filters?search=spam'); + + $response->assertOk() + ->assertJsonCount(1, 'data'); + }); + + test('can include creator information', function () { + $user = User::factory()->create(); + WordFilter::factory()->create(['created_by' => $user->id]); + + $response = $this->getJson('/api/word-filters?with_creator=1'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'creator' => ['id', 'name', 'email'], + ], + ], + ]); + }); + + test('can sort results', function () { + WordFilter::factory()->create(['pattern' => 'aaa']); + WordFilter::factory()->create(['pattern' => 'zzz']); + + $response = $this->getJson('/api/word-filters?sort_by=pattern&sort_order=asc'); + + $response->assertOk() + ->assertJsonPath('data.0.pattern', 'aaa') + ->assertJsonPath('data.1.pattern', 'zzz'); + }); + }); + + describe('Search', function () { + test('can search word filters', function () { + WordFilter::factory()->create(['pattern' => 'badword']); + WordFilter::factory()->create(['pattern' => 'goodword']); + + $response = $this->getJson('/api/word-filters/search?q=bad'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.pattern', 'badword'); + }); + + test('search requires query parameter', function () { + $response = $this->getJson('/api/word-filters/search'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['q']); + }); + + test('can limit search results', function () { + WordFilter::factory()->count(10)->create(['pattern' => 'test']); + + $response = $this->getJson('/api/word-filters/search?q=test&limit=5'); + + $response->assertOk() + ->assertJsonCount(5, 'data'); + }); + }); + + describe('Show', function () { + test('can get a single word filter', function () { + $filter = WordFilter::factory()->create(); + + $response = $this->getJson("/api/word-filters/{$filter->id}"); + + $response->assertOk() + ->assertJsonPath('data.id', $filter->id) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'pattern', + 'replacement', + 'filter_type', + 'pattern_type', + 'severity', + 'is_active', + 'case_sensitive', + 'applies_to', + 'notes', + 'creator', + 'created_at', + 'updated_at', + ], + ]); + }); + + test('returns 404 for non-existent filter', function () { + $response = $this->getJson('/api/word-filters/999'); + + $response->assertNotFound(); + }); + }); + + describe('Store', function () { + test('can create a replace filter', function () { + $data = [ + 'pattern' => 'badword', + 'replacement' => '******', + 'filter_type' => 'replace', + 'pattern_type' => 'exact', + 'severity' => 'high', + 'applies_to' => ['posts', 'private_messages'], + 'notes' => 'Common profanity', + ]; + + $response = $this->postJson('/api/word-filters', $data); + + $response->assertCreated() + ->assertJsonPath('data.pattern', 'badword') + ->assertJsonPath('data.replacement', '******'); + + $this->assertDatabaseHas('word_filters', [ + 'pattern' => 'badword', + 'filter_type' => 'replace', + ]); + }); + + test('can create a block filter', function () { + $data = [ + 'pattern' => '*spam*', + 'filter_type' => 'block', + 'pattern_type' => 'wildcard', + 'severity' => 'medium', + 'applies_to' => ['posts'], + ]; + + $response = $this->postJson('/api/word-filters', $data); + + $response->assertCreated() + ->assertJsonPath('data.filter_type', 'block') + ->assertJsonPath('data.replacement', null); + }); + + test('can create a regex filter', function () { + $data = [ + 'pattern' => '/\\b\\d{3}-\\d{4}\\b/', + 'filter_type' => 'moderate', + 'pattern_type' => 'regex', + 'severity' => 'low', + 'applies_to' => ['posts', 'signatures'], + ]; + + $response = $this->postJson('/api/word-filters', $data); + + $response->assertCreated() + ->assertJsonPath('data.pattern_type', 'regex'); + }); + + test('validates required fields', function () { + $response = $this->postJson('/api/word-filters', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors([ + 'pattern', + 'filter_type', + 'pattern_type', + 'severity', + 'applies_to', + ]); + }); + + test('requires replacement for replace filter type', function () { + $data = [ + 'pattern' => 'test', + 'filter_type' => 'replace', + 'pattern_type' => 'exact', + 'severity' => 'low', + 'applies_to' => ['posts'], + ]; + + $response = $this->postJson('/api/word-filters', $data); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['replacement']); + }); + + test('validates regex patterns', function () { + $data = [ + 'pattern' => '[invalid regex', + 'filter_type' => 'moderate', + 'pattern_type' => 'regex', + 'severity' => 'low', + 'applies_to' => ['posts'], + ]; + + $response = $this->postJson('/api/word-filters', $data); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['pattern']); + }); + + test('validates enum values', function () { + $data = [ + 'pattern' => 'test', + 'replacement' => '****', + 'filter_type' => 'invalid', + 'pattern_type' => 'invalid', + 'severity' => 'invalid', + 'applies_to' => ['invalid'], + ]; + + $response = $this->postJson('/api/word-filters', $data); + + $response->assertStatus(422) + ->assertJsonValidationErrors([ + 'filter_type', + 'pattern_type', + 'severity', + 'applies_to.0', + ]); + }); + }); + + describe('Update', function () { + test('can update a word filter', function () { + $filter = WordFilter::factory()->create([ + 'pattern' => 'oldword', + 'pattern_type' => 'exact', + 'severity' => 'low', + ]); + + $response = $this->patchJson("/api/word-filters/{$filter->id}", [ + 'pattern' => 'newword', + 'severity' => 'high', + ]); + + $response->assertOk() + ->assertJsonPath('data.pattern', 'newword') + ->assertJsonPath('data.severity', 'high'); + + $this->assertDatabaseHas('word_filters', [ + 'id' => $filter->id, + 'pattern' => 'newword', + 'severity' => 'high', + ]); + }); + + test('changing to replace type requires replacement', function () { + $filter = WordFilter::factory()->create([ + 'filter_type' => 'block', + 'replacement' => null, + ]); + + $response = $this->patchJson("/api/word-filters/{$filter->id}", [ + 'filter_type' => 'replace', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['replacement']); + }); + + test('validates regex pattern on update', function () { + $filter = WordFilter::factory()->create([ + 'pattern_type' => 'exact', + ]); + + $response = $this->patchJson("/api/word-filters/{$filter->id}", [ + 'pattern_type' => 'regex', + 'pattern' => '[invalid', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['pattern']); + }); + }); + + describe('Destroy', function () { + test('can delete a word filter', function () { + $filter = WordFilter::factory()->create(); + + $response = $this->deleteJson("/api/word-filters/{$filter->id}"); + + $response->assertNoContent(); + $this->assertDatabaseMissing('word_filters', ['id' => $filter->id]); + }); + + test('returns 404 when deleting non-existent filter', function () { + $response = $this->deleteJson('/api/word-filters/999'); + + $response->assertNotFound(); + }); + }); +});