Add comment spam classification and captcha checks.

Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
This commit is contained in:
test
2026-09-20 14:48:50 +02:00
parent 5c705c6490
commit 3078319f3c
16 changed files with 858 additions and 135 deletions
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\CommentSpamSignature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('increments signature hit_count when a known spam comment is posted', function (): void {
config([
'comment_spam.enabled' => true,
'comment_spam.mode' => 'enforce',
'comment_spam.ai_enabled' => false,
'comment_spam.captcha.enabled' => false,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$content = 'copy paste this known spam signature';
$hash = hash('sha256', mb_strtolower((string) preg_replace('/\s+/u', ' ', trim($content))));
$signature = CommentSpamSignature::query()->create([
'content_hash' => $hash,
'source' => 'spam',
'reason' => 'Known promotional spam.',
'confidence' => 100,
'hit_count' => 0,
'created_at' => now(),
]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/comments", [
'content' => $content,
])
->assertCreated();
expect((int) $signature->fresh()->hit_count)->toBe(1);
$comment = ArtworkComment::query()->latest('id')->first();
expect($comment)->not->toBeNull()
->and((bool) $comment->is_approved)->toBeFalse()
->and((string) $comment->moderation_source)->toBe('signature');
});
@@ -0,0 +1,66 @@
<?php
use App\Services\Moderation\TogetherCommentSpamClassifier;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function (): void {
config([
'comment_spam.ai.api_key' => 'test-key',
'comment_spam.ai.base_url' => 'https://api.together.test/v1',
'comment_spam.ai.model' => 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
]);
});
it('classifies using the strict Together JSON contract', function (string $model): void {
config(['comment_spam.ai.model' => $model]);
Http::fake([
'https://api.together.test/*' => Http::response([
'choices' => [['message' => ['content' => '{"spam":true,"confidence":0.95,"reason":"Unsolicited promotion."}']]],
]),
]);
$result = app(TogetherCommentSpamClassifier::class)->classify('Buy now at example.test');
expect($result->spam)->toBeTrue()
->and($result->confidence)->toBe(0.95)
->and($result->provider)->toBe('together')
->and($result->model)->toBe($model);
})->with([
'Llama' => ['meta-llama/Llama-3.2-3B-Instruct-Turbo'],
'Bonsai' => ['Prism-ML/Ternary-Bonsai-27B'],
]);
it('rejects an unsupported configured model before making an HTTP request', function (): void {
config(['comment_spam.ai.model' => 'some/unsupported-model']);
Http::fake();
expect(fn () => app(TogetherCommentSpamClassifier::class)->classify('hello'))
->toThrow(RuntimeException::class, 'Unsupported Together AI comment spam model.');
Http::assertNothingSent();
});
it('rejects malformed or out of range classifier output', function (array $json): void {
Http::fake([
'https://api.together.test/*' => Http::response([
'choices' => [['message' => ['content' => json_encode($json)]]],
]),
]);
expect(fn () => app(TogetherCommentSpamClassifier::class)->classify('hello'))
->toThrow(RuntimeException::class);
})->with([
[['spam' => 'true', 'confidence' => 0.9, 'reason' => 'bad type']],
[['spam' => true, 'confidence' => 1.1, 'reason' => 'out of range']],
[['spam' => true, 'confidence' => 0.9]],
]);
it('fails closed when Together is not configured', function (): void {
config(['comment_spam.ai.api_key' => '']);
expect(fn () => app(TogetherCommentSpamClassifier::class)->classify('hello'))
->toThrow(RuntimeException::class);
});