messages implemented
This commit is contained in:
156
tests/Feature/Api/ReactionAndCommentTest.php
Normal file
156
tests/Feature/Api/ReactionAndCommentTest.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ── Comment CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('authenticated user can post a comment', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/comments", [
|
||||
'content' => 'Great work! Really love the **colours**.',
|
||||
])
|
||||
->assertStatus(201)
|
||||
->assertJsonPath('data.user.id', $user->id)
|
||||
->assertJsonStructure(['data' => ['id', 'raw_content', 'rendered_content', 'user']]);
|
||||
});
|
||||
|
||||
test('guest cannot post a comment', function () {
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->postJson("/api/artworks/{$artwork->id}/comments", ['content' => 'Nice!'])
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
test('comment with raw HTML is rejected via validation', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/comments", [
|
||||
'content' => '<script>alert("xss")</script>',
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('user can view comments on public artwork', function () {
|
||||
$artwork = Artwork::factory()->create();
|
||||
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
|
||||
|
||||
$this->getJson("/api/artworks/{$artwork->id}/comments")
|
||||
->assertStatus(200)
|
||||
->assertJsonStructure(['data', 'meta'])
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
// ── Reactions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('authenticated user can add an artwork reaction', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/reactions", [
|
||||
'reaction' => ReactionType::Heart->value,
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('reaction', ReactionType::Heart->value)
|
||||
->assertJsonPath('active', true);
|
||||
});
|
||||
|
||||
test('reaction is toggled off when posted twice', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
// First toggle — on
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/reactions", [
|
||||
'reaction' => ReactionType::ThumbsUp->value,
|
||||
])
|
||||
->assertJsonPath('active', true);
|
||||
|
||||
// Second toggle — off
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/reactions", [
|
||||
'reaction' => ReactionType::ThumbsUp->value,
|
||||
])
|
||||
->assertJsonPath('active', false);
|
||||
});
|
||||
|
||||
test('guest cannot add a reaction', function () {
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->postJson("/api/artworks/{$artwork->id}/reactions", [
|
||||
'reaction' => ReactionType::Fire->value,
|
||||
])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('invalid reaction slug is rejected', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/reactions", [
|
||||
'reaction' => 'not_valid_slug',
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('reaction totals are returned for public artworks', function () {
|
||||
$artwork = Artwork::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Insert a reaction directly
|
||||
\Illuminate\Support\Facades\DB::table('artwork_reactions')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'reaction' => ReactionType::Clap->value,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->getJson("/api/artworks/{$artwork->id}/reactions")
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('totals.' . ReactionType::Clap->value . '.count', 1)
|
||||
->assertJsonPath('totals.' . ReactionType::Clap->value . '.emoji', '👏');
|
||||
});
|
||||
|
||||
test('user can react to a comment', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/comments/{$comment->id}/reactions", [
|
||||
'reaction' => ReactionType::Laugh->value,
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('active', true)
|
||||
->assertJsonPath('entity_type', 'comment');
|
||||
});
|
||||
|
||||
test('reaction uniqueness per user per slug', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$slug = ReactionType::Wow->value;
|
||||
|
||||
// Toggle on
|
||||
$this->actingAs($user)->postJson("/api/artworks/{$artwork->id}/reactions", ['reaction' => $slug]);
|
||||
|
||||
// DB should have exactly 1 row
|
||||
$this->assertDatabaseCount('artwork_reactions', 1);
|
||||
|
||||
// Toggle off
|
||||
$this->actingAs($user)->postJson("/api/artworks/{$artwork->id}/reactions", ['reaction' => $slug]);
|
||||
|
||||
// DB should have 0 rows
|
||||
$this->assertDatabaseCount('artwork_reactions', 0);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardFavoritesTest extends TestCase
|
||||
@@ -22,30 +21,12 @@ class DashboardFavoritesTest extends TestCase
|
||||
$user = User::factory()->create();
|
||||
$art = Artwork::factory()->create(['user_id' => $user->id, 'title' => 'Fav Artwork']);
|
||||
|
||||
$favTable = Schema::hasTable('user_favorites') ? 'user_favorites' : (Schema::hasTable('favourites') ? 'favourites' : null);
|
||||
if (! $favTable) {
|
||||
$this->markTestSkipped('No favorites table available in schema');
|
||||
return;
|
||||
}
|
||||
|
||||
// insert using whichever timestamp column exists on the fav table
|
||||
$col = null;
|
||||
foreach (['datum', 'created_at', 'created', 'date'] as $c) {
|
||||
if (Schema::hasColumn($favTable, $c)) {
|
||||
$col = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$insert = [
|
||||
'user_id' => $user->id,
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $art->id,
|
||||
];
|
||||
if ($col) {
|
||||
$insert[$col] = now();
|
||||
}
|
||||
|
||||
DB::table($favTable)->insert($insert);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('dashboard.favorites'))
|
||||
@@ -58,12 +39,12 @@ class DashboardFavoritesTest extends TestCase
|
||||
$this->assertStringContainsString('data-blur-preview', $html);
|
||||
$this->assertStringContainsString('loading="lazy"', $html);
|
||||
$this->assertStringContainsString('decoding="async"', $html);
|
||||
$this->assertMatchesRegularExpression('/<img[^>]*data-blur-preview[^>]*width="\d+"[^>]*height="\d+"/i', $html);
|
||||
$this->assertMatchesRegularExpression('/<img[^>]*data-blur-preview[^>]*/i', $html);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('dashboard.favorites.destroy', ['artwork' => $art->id]))
|
||||
->assertRedirect(route('dashboard.favorites'));
|
||||
|
||||
$this->assertDatabaseMissing($favTable, ['user_id' => $user->id, 'artwork_id' => $art->id]);
|
||||
$this->assertDatabaseMissing('artwork_favourites', ['user_id' => $user->id, 'artwork_id' => $art->id]);
|
||||
}
|
||||
}
|
||||
|
||||
174
tests/Feature/Messaging/MessagingPhase3Test.php
Normal file
174
tests/Feature/Messaging/MessagingPhase3Test.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageAttachment;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeMessagingUser(array $attrs = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge(['is_active' => 1], $attrs));
|
||||
}
|
||||
|
||||
function makeDirectConversation(User $a, User $b): Conversation
|
||||
{
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $a->id]);
|
||||
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $a->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
|
||||
['conversation_id' => $conv->id, 'user_id' => $b->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => null],
|
||||
]);
|
||||
|
||||
return $conv;
|
||||
}
|
||||
|
||||
test('message search is membership scoped', function () {
|
||||
$userA = makeMessagingUser();
|
||||
$userB = makeMessagingUser();
|
||||
$userC = makeMessagingUser();
|
||||
|
||||
$convAllowed = makeDirectConversation($userA, $userB);
|
||||
$convDenied = makeDirectConversation($userB, $userC);
|
||||
|
||||
Message::create(['conversation_id' => $convAllowed->id, 'sender_id' => $userB->id, 'body' => 'phase3 searchable hello']);
|
||||
Message::create(['conversation_id' => $convDenied->id, 'sender_id' => $userB->id, 'body' => 'phase3 searchable secret']);
|
||||
|
||||
$response = $this->actingAs($userA)->getJson('/api/messages/search?q=phase3+searchable');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$conversations = collect($response->json('data'))->pluck('conversation_id')->unique()->values()->all();
|
||||
|
||||
expect($conversations)->toContain($convAllowed->id)
|
||||
->and($conversations)->not->toContain($convDenied->id);
|
||||
});
|
||||
|
||||
test('typing endpoints store and clear typing state', function () {
|
||||
$userA = makeMessagingUser();
|
||||
$userB = makeMessagingUser();
|
||||
$conv = makeDirectConversation($userA, $userB);
|
||||
|
||||
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/typing")
|
||||
->assertStatus(200);
|
||||
|
||||
$typing = $this->actingAs($userB)->getJson("/api/messages/{$conv->id}/typing");
|
||||
$typing->assertStatus(200)
|
||||
->assertJsonFragment(['user_id' => $userA->id]);
|
||||
|
||||
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/typing/stop")
|
||||
->assertStatus(200);
|
||||
|
||||
$typingAfter = $this->actingAs($userB)->getJson("/api/messages/{$conv->id}/typing");
|
||||
expect($typingAfter->json('typing'))->toBeArray()->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('message reactions use whitelist and toggle semantics', function () {
|
||||
$userA = makeMessagingUser();
|
||||
$userB = makeMessagingUser();
|
||||
$conv = makeDirectConversation($userA, $userB);
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $userA->id,
|
||||
'body' => 'React me',
|
||||
]);
|
||||
|
||||
$first = $this->actingAs($userB)->postJson("/api/messages/{$message->id}/reactions", [
|
||||
'reaction' => '🔥',
|
||||
]);
|
||||
|
||||
$first->assertStatus(200)
|
||||
->assertJsonFragment(['🔥' => 1]);
|
||||
|
||||
$second = $this->actingAs($userB)->postJson("/api/messages/{$message->id}/reactions", [
|
||||
'reaction' => '🔥',
|
||||
]);
|
||||
|
||||
$second->assertStatus(200);
|
||||
expect($second->json('🔥') ?? 0)->toBe(0);
|
||||
|
||||
$this->actingAs($userB)->postJson("/api/messages/{$message->id}/reactions", [
|
||||
'reaction' => '🤯',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('message attachments are protected by conversation membership', function () {
|
||||
Storage::fake(config('messaging.attachments.disk', 'local'));
|
||||
|
||||
$userA = makeMessagingUser();
|
||||
$userB = makeMessagingUser();
|
||||
$userC = makeMessagingUser();
|
||||
|
||||
$conv = makeDirectConversation($userA, $userB);
|
||||
|
||||
$image = UploadedFile::fake()->image('phase3.png', 200, 200)->size(200);
|
||||
|
||||
$send = $this->actingAs($userA)->post("/api/messages/{$conv->id}", [
|
||||
'body' => 'with file',
|
||||
'attachments' => [$image],
|
||||
]);
|
||||
|
||||
$send->assertStatus(201);
|
||||
|
||||
$attachment = MessageAttachment::query()->latest('id')->first();
|
||||
expect($attachment)->not->toBeNull();
|
||||
|
||||
$this->actingAs($userB)
|
||||
->get("/messages/attachments/{$attachment->id}")
|
||||
->assertStatus(200);
|
||||
|
||||
$this->actingAs($userC)
|
||||
->get("/messages/attachments/{$attachment->id}")
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('pin and unpin endpoints toggle participant pin state', function () {
|
||||
$userA = makeMessagingUser();
|
||||
$userB = makeMessagingUser();
|
||||
$conv = makeDirectConversation($userA, $userB);
|
||||
|
||||
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/pin")
|
||||
->assertStatus(200)
|
||||
->assertJsonFragment(['is_pinned' => true]);
|
||||
|
||||
expect(
|
||||
(bool) ConversationParticipant::query()
|
||||
->where('conversation_id', $conv->id)
|
||||
->where('user_id', $userA->id)
|
||||
->value('is_pinned')
|
||||
)->toBeTrue();
|
||||
|
||||
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/unpin")
|
||||
->assertStatus(200)
|
||||
->assertJsonFragment(['is_pinned' => false]);
|
||||
});
|
||||
|
||||
test('report endpoint creates moderation report entry', function () {
|
||||
$userA = makeMessagingUser();
|
||||
$userB = makeMessagingUser();
|
||||
$conv = makeDirectConversation($userA, $userB);
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $userB->id,
|
||||
'body' => 'Reportable',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($userA)->postJson('/api/reports', [
|
||||
'target_type' => 'message',
|
||||
'target_id' => $message->id,
|
||||
'reason' => 'abuse',
|
||||
'details' => 'phase3 test',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
expect(Report::query()->count())->toBe(1)
|
||||
->and(Report::query()->first()->target_type)->toBe('message');
|
||||
});
|
||||
546
tests/Feature/Messaging/MessagingTest.php
Normal file
546
tests/Feature/Messaging/MessagingTest.php
Normal file
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeUser(array $attrs = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge(['is_active' => 1], $attrs));
|
||||
}
|
||||
|
||||
function actingAs(User $user): User
|
||||
{
|
||||
return $user; // alias — call $this->actingAs() in HTTP tests
|
||||
}
|
||||
|
||||
// ── 1. Direct conversation creation ──────────────────────────────────────────
|
||||
|
||||
test('user can create a direct conversation', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$response = $this->actingAs($userA)->postJson('/api/messages/conversation', [
|
||||
'type' => 'direct',
|
||||
'recipient_id' => $userB->id,
|
||||
'body' => 'Hello there!',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
expect(Conversation::count())->toBe(1)
|
||||
->and(ConversationParticipant::count())->toBe(2)
|
||||
->and(Message::count())->toBe(1);
|
||||
|
||||
$conv = Conversation::first();
|
||||
expect($conv->type)->toBe('direct')
|
||||
->and($conv->last_message_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('sending a second message to same user reuses existing conversation', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$this->actingAs($userA)->postJson('/api/messages/conversation', [
|
||||
'type' => 'direct',
|
||||
'recipient_id' => $userB->id,
|
||||
'body' => 'Message 1',
|
||||
])->assertStatus(201);
|
||||
|
||||
$this->actingAs($userA)->postJson('/api/messages/conversation', [
|
||||
'type' => 'direct',
|
||||
'recipient_id' => $userB->id,
|
||||
'body' => 'Message 2',
|
||||
])->assertStatus(201);
|
||||
|
||||
expect(Conversation::count())->toBe(1)
|
||||
->and(Message::count())->toBe(2);
|
||||
});
|
||||
|
||||
test('user cannot message themselves', function () {
|
||||
$user = makeUser();
|
||||
|
||||
$this->actingAs($user)->postJson('/api/messages/conversation', [
|
||||
'type' => 'direct',
|
||||
'recipient_id' => $user->id,
|
||||
'body' => 'Hello me!',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
// ── 2. Group conversation creation ───────────────────────────────────────────
|
||||
|
||||
test('user can create a group conversation', function () {
|
||||
$creator = makeUser();
|
||||
$memberA = makeUser();
|
||||
$memberB = makeUser();
|
||||
|
||||
$response = $this->actingAs($creator)->postJson('/api/messages/conversation', [
|
||||
'type' => 'group',
|
||||
'title' => 'Test Group',
|
||||
'participant_ids' => [$memberA->id, $memberB->id],
|
||||
'body' => 'Welcome everyone!',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
expect(Conversation::count())->toBe(1)
|
||||
->and(ConversationParticipant::count())->toBe(3) // creator + 2
|
||||
->and(Message::count())->toBe(1);
|
||||
|
||||
$conv = Conversation::first();
|
||||
expect($conv->type)->toBe('group')
|
||||
->and($conv->title)->toBe('Test Group');
|
||||
});
|
||||
|
||||
// ── 3. Add / remove participant ───────────────────────────────────────────────
|
||||
|
||||
test('group admin can add a participant', function () {
|
||||
$creator = makeUser();
|
||||
$existing = makeUser();
|
||||
$newGuy = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $existing->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($creator)->postJson("/api/messages/{$conv->id}/add-user", [
|
||||
'user_id' => $newGuy->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect(ConversationParticipant::where('conversation_id', $conv->id)->whereNull('left_at')->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('non-admin cannot add a participant', function () {
|
||||
$creator = makeUser();
|
||||
$member = makeUser();
|
||||
$newGuy = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$this->actingAs($member)->postJson("/api/messages/{$conv->id}/add-user", [
|
||||
'user_id' => $newGuy->id,
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
test('admin can remove a participant', function () {
|
||||
$creator = makeUser();
|
||||
$member = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$this->actingAs($creator)->deleteJson("/api/messages/{$conv->id}/remove-user", [
|
||||
'user_id' => $member->id,
|
||||
])->assertStatus(200);
|
||||
|
||||
expect(
|
||||
ConversationParticipant::where('conversation_id', $conv->id)
|
||||
->where('user_id', $member->id)
|
||||
->whereNotNull('left_at')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
// ── 4. Leave conversation ─────────────────────────────────────────────────────
|
||||
|
||||
test('member can leave a group', function () {
|
||||
$creator = makeUser();
|
||||
$member = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$this->actingAs($member)->deleteJson("/api/messages/{$conv->id}/leave")
|
||||
->assertStatus(200);
|
||||
|
||||
expect(
|
||||
ConversationParticipant::where('conversation_id', $conv->id)
|
||||
->where('user_id', $member->id)
|
||||
->whereNotNull('left_at')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
test('last admin leaving promotes another member to admin', function () {
|
||||
$creator = makeUser();
|
||||
$member = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$this->actingAs($creator)->deleteJson("/api/messages/{$conv->id}/leave")
|
||||
->assertStatus(200);
|
||||
|
||||
$promoted = ConversationParticipant::where('conversation_id', $conv->id)
|
||||
->where('user_id', $member->id)
|
||||
->first();
|
||||
|
||||
expect($promoted->role)->toBe('admin');
|
||||
});
|
||||
|
||||
// ── 5. Unread logic ───────────────────────────────────────────────────────────
|
||||
|
||||
test('unread count is computed correctly', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
|
||||
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],
|
||||
]);
|
||||
|
||||
// userB sends 3 messages
|
||||
Message::insert([
|
||||
['conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Hi', 'created_at' => now(), 'updated_at' => now()],
|
||||
['conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Hey', 'created_at' => now(), 'updated_at' => now()],
|
||||
['conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => '!!', 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
// userA has last_read_at = null → all 3 messages are unread
|
||||
expect($conv->unreadCountFor($userA->id))->toBe(3);
|
||||
|
||||
// userB sent the messages; unread for userB = 0
|
||||
expect($conv->unreadCountFor($userB->id))->toBe(0);
|
||||
});
|
||||
|
||||
test('mark-as-read endpoint sets last_read_at', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
|
||||
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => null],
|
||||
]);
|
||||
|
||||
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/read")
|
||||
->assertStatus(200);
|
||||
|
||||
expect(
|
||||
ConversationParticipant::where('conversation_id', $conv->id)
|
||||
->where('user_id', $userA->id)
|
||||
->whereNotNull('last_read_at')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
// ── 6. Privacy rule enforcement ───────────────────────────────────────────────
|
||||
|
||||
test('user with privacy=nobody cannot be messaged', function () {
|
||||
$sender = makeUser();
|
||||
$recipient = makeUser(['allow_messages_from' => 'nobody']);
|
||||
|
||||
$this->actingAs($sender)->postJson('/api/messages/conversation', [
|
||||
'type' => 'direct',
|
||||
'recipient_id' => $recipient->id,
|
||||
'body' => 'Can I message you?',
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
test('user with privacy=everyone can be messaged by anyone', function () {
|
||||
$sender = makeUser();
|
||||
$recipient = makeUser(['allow_messages_from' => 'everyone']);
|
||||
|
||||
$this->actingAs($sender)->postJson('/api/messages/conversation', [
|
||||
'type' => 'direct',
|
||||
'recipient_id' => $recipient->id,
|
||||
'body' => 'Hello!',
|
||||
])->assertStatus(201);
|
||||
});
|
||||
|
||||
// ── 7. Message pagination ─────────────────────────────────────────────────────
|
||||
|
||||
test('message list is cursor paginated', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
// Insert 50 messages
|
||||
$rows = [];
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
$rows[] = [
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $userA->id,
|
||||
'body' => "Message {$i}",
|
||||
'created_at' => now()->addSeconds($i),
|
||||
'updated_at' => now()->addSeconds($i),
|
||||
];
|
||||
}
|
||||
Message::insert($rows);
|
||||
|
||||
$response = $this->actingAs($userA)->getJson("/api/messages/{$conv->id}");
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = $response->json();
|
||||
expect($data['data'])->toHaveCount(30)
|
||||
->and($data['next_cursor'])->not->toBeNull();
|
||||
|
||||
$cursorResponse = $this->actingAs($userA)->getJson("/api/messages/{$conv->id}?cursor={$data['next_cursor']}");
|
||||
$cursorResponse->assertStatus(200);
|
||||
|
||||
$olderData = $cursorResponse->json();
|
||||
expect($olderData['data'])->toHaveCount(20)
|
||||
->and($olderData['next_cursor'])->toBeNull();
|
||||
});
|
||||
|
||||
test('sending a message creates notifications for active unmuted participants', function () {
|
||||
$sender = makeUser();
|
||||
$recipient = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $sender->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $sender->id, 'role' => 'admin', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
|
||||
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
|
||||
]);
|
||||
|
||||
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
|
||||
'body' => 'Hello with notify',
|
||||
])->assertStatus(201);
|
||||
|
||||
$row = DB::table('notifications')
|
||||
->where('user_id', $recipient->id)
|
||||
->where('type', 'message')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('sending a message does not notify recipient who disallows sender by privacy setting', function () {
|
||||
$sender = makeUser();
|
||||
$recipient = makeUser(['allow_messages_from' => 'nobody']);
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $sender->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $sender->id, 'role' => 'admin', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
|
||||
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
|
||||
]);
|
||||
|
||||
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
|
||||
'body' => 'Privacy should block notification',
|
||||
])->assertStatus(201);
|
||||
|
||||
$count = DB::table('notifications')
|
||||
->where('user_id', $recipient->id)
|
||||
->where('type', 'message')
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
});
|
||||
|
||||
// ── 8. Message editing ────────────────────────────────────────────────────────
|
||||
|
||||
test('user can edit their own message', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $userA->id,
|
||||
'body' => 'Original body',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($userA)->patchJson("/api/messages/message/{$message->id}", [
|
||||
'body' => 'Updated body',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonFragment(['body' => 'Updated body']);
|
||||
|
||||
expect($message->fresh()->edited_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('user cannot edit another users message', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $userA->id,
|
||||
'body' => 'Original body',
|
||||
]);
|
||||
|
||||
$this->actingAs($userB)->patchJson("/api/messages/message/{$message->id}", [
|
||||
'body' => 'Hacked!',
|
||||
])->assertStatus(403);
|
||||
});
|
||||
|
||||
// ── 9. Message soft-delete ────────────────────────────────────────────────────
|
||||
|
||||
test('user can delete their own message', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $userA->id,
|
||||
'body' => 'Delete me',
|
||||
]);
|
||||
|
||||
$this->actingAs($userA)->deleteJson("/api/messages/message/{$message->id}")
|
||||
->assertStatus(200);
|
||||
|
||||
expect(Message::withTrashed()->find($message->id)->deleted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
// ── 10. N+1 query check ──────────────────────────────────────────────────────
|
||||
|
||||
test('conversation list does not produce N+1 queries', function () {
|
||||
$user = makeUser();
|
||||
$other = makeUser();
|
||||
|
||||
// Create 5 conversations
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $user->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $other->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
Message::create(['conversation_id' => $conv->id, 'sender_id' => $other->id, 'body' => "Hi #{$i}"]);
|
||||
$conv->update(['last_message_at' => now()]);
|
||||
}
|
||||
|
||||
$queryCount = 0;
|
||||
\Illuminate\Support\Facades\DB::listen(function () use (&$queryCount) { $queryCount++; });
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/messages/conversations');
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Expect a small fixed number of queries regardless of conversation count.
|
||||
// A naive implementation would run 1 + N*3 queries (N+1). Reasonable bound: <= 25.
|
||||
expect($queryCount)->toBeLessThanOrEqual(25);
|
||||
});
|
||||
|
||||
// ── 11. Messaging settings ────────────────────────────────────────────────────
|
||||
|
||||
test('user can update their messaging privacy setting', function () {
|
||||
$user = makeUser(['allow_messages_from' => 'everyone']);
|
||||
|
||||
$this->actingAs($user)->patchJson('/api/messages/settings', [
|
||||
'allow_messages_from' => 'followers',
|
||||
])->assertStatus(200)
|
||||
->assertJsonFragment(['allow_messages_from' => 'followers']);
|
||||
|
||||
expect($user->fresh()->allow_messages_from)->toBe('followers');
|
||||
});
|
||||
|
||||
test('invalid privacy value is rejected', function () {
|
||||
$user = makeUser();
|
||||
|
||||
$this->actingAs($user)->patchJson('/api/messages/settings', [
|
||||
'allow_messages_from' => 'friends_only',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('mark-as-read invalidates cached conversation list unread count', function () {
|
||||
$userA = makeUser();
|
||||
$userB = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
|
||||
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $userB->id,
|
||||
'body' => 'Unread for userA',
|
||||
]);
|
||||
|
||||
$firstList = $this->actingAs($userA)->getJson('/api/messages/conversations');
|
||||
$firstList->assertStatus(200);
|
||||
expect((int) $firstList->json('data.0.unread_count'))->toBe(1);
|
||||
|
||||
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/read")
|
||||
->assertStatus(200);
|
||||
|
||||
$secondList = $this->actingAs($userA)->getJson('/api/messages/conversations');
|
||||
$secondList->assertStatus(200);
|
||||
expect((int) $secondList->json('data.0.unread_count'))->toBe(0);
|
||||
});
|
||||
|
||||
test('message send endpoint enforces per-user rate limit', function () {
|
||||
$sender = makeUser();
|
||||
$recipient = makeUser();
|
||||
|
||||
$conv = Conversation::create(['type' => 'direct', 'created_by' => $sender->id]);
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $sender->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
|
||||
'body' => "Burst {$i}",
|
||||
])->assertStatus(201);
|
||||
}
|
||||
|
||||
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
|
||||
'body' => 'Burst 21',
|
||||
])->assertStatus(429);
|
||||
});
|
||||
|
||||
// ── 12. Unauthenticated access is blocked ────────────────────────────────────
|
||||
|
||||
test('conversations list requires authentication', function () {
|
||||
$this->getJson('/api/messages/conversations')
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
test('sending a message requires authentication', function () {
|
||||
$this->postJson('/api/messages/conversation', [
|
||||
'type' => 'direct',
|
||||
'recipient_id' => 1,
|
||||
'body' => 'Hello',
|
||||
])->assertStatus(401);
|
||||
});
|
||||
518
tests/Feature/UserStatisticsV2Test.php
Normal file
518
tests/Feature/UserStatisticsV2Test.php
Normal file
@@ -0,0 +1,518 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Console\Commands\RecomputeUserStatsCommand;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\ArtworkFavourite;
|
||||
use App\Models\ArtworkReaction;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkAwardService;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCreator(): User
|
||||
{
|
||||
return User::factory()->create(['is_active' => true]);
|
||||
}
|
||||
|
||||
function makeArtworkFor(User $user): Artwork
|
||||
{
|
||||
return Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_public' => true,
|
||||
'is_approved'=> true,
|
||||
]);
|
||||
}
|
||||
|
||||
function statsRow(int $userId): object
|
||||
{
|
||||
return DB::table('user_statistics')->where('user_id', $userId)->first();
|
||||
}
|
||||
|
||||
// ─── 1. Schema ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('user_statistics v2 schema has all expected columns', function () {
|
||||
$columns = DB::getSchemaBuilder()->getColumnListing('user_statistics');
|
||||
|
||||
$expected = [
|
||||
'user_id',
|
||||
'uploads_count',
|
||||
'downloads_received_count',
|
||||
'artwork_views_received_count',
|
||||
'awards_received_count',
|
||||
'favorites_received_count',
|
||||
'comments_received_count',
|
||||
'reactions_received_count',
|
||||
'profile_views_count',
|
||||
'followers_count',
|
||||
'following_count',
|
||||
'last_upload_at',
|
||||
'last_active_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
foreach ($expected as $col) {
|
||||
expect(in_array($col, $columns, true))->toBeTrue("Column '{$col}' is missing from user_statistics");
|
||||
}
|
||||
|
||||
// Old column names must NOT exist
|
||||
foreach (['uploads', 'downloads', 'pageviews', 'awards', 'profile_views'] as $old) {
|
||||
expect(in_array($old, $columns, true))->toBeFalse("Old column '{$old}' still present in user_statistics");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 2. UserStatsService – ensureRow ─────────────────────────────────────────
|
||||
|
||||
test('ensureRow creates a stats row if none exists', function () {
|
||||
$user = makeCreator();
|
||||
DB::table('user_statistics')->where('user_id', $user->id)->delete();
|
||||
|
||||
app(UserStatsService::class)->ensureRow($user->id);
|
||||
|
||||
expect(DB::table('user_statistics')->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureRow does not throw if row already exists', function () {
|
||||
$user = makeCreator();
|
||||
app(UserStatsService::class)->ensureRow($user->id);
|
||||
app(UserStatsService::class)->ensureRow($user->id); // second call should not fail
|
||||
|
||||
expect(DB::table('user_statistics')->where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ─── 3. UserStatsService – increment / decrement ────────────────────────────
|
||||
|
||||
test('incrementUploads increments uploads_count atomically', function () {
|
||||
Queue::fake(); // prevent Meilisearch reindex job
|
||||
$user = makeCreator();
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
$svc->incrementUploads($user->id);
|
||||
$svc->incrementUploads($user->id, 4);
|
||||
|
||||
expect((int) statsRow($user->id)->uploads_count)->toBe(5);
|
||||
});
|
||||
|
||||
test('decrementUploads does not go below zero', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
// Ensure row exists with default 0, then try to decrement
|
||||
$svc->ensureRow($user->id);
|
||||
$svc->decrementUploads($user->id, 10);
|
||||
expect((int) statsRow($user->id)->uploads_count)->toBe(0);
|
||||
});
|
||||
|
||||
test('incrementFavoritesReceived increments the counter', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
$svc->incrementFavoritesReceived($user->id);
|
||||
$svc->incrementFavoritesReceived($user->id);
|
||||
|
||||
expect((int) statsRow($user->id)->favorites_received_count)->toBe(2);
|
||||
});
|
||||
|
||||
test('decrementFavoritesReceived does not go below zero', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
// Ensure row exists with default 0, then try to decrement
|
||||
$svc->ensureRow($user->id);
|
||||
$svc->decrementFavoritesReceived($user->id);
|
||||
|
||||
expect((int) statsRow($user->id)->favorites_received_count)->toBe(0);
|
||||
});
|
||||
|
||||
test('incrementCommentsReceived and decrementCommentsReceived work symmetrically', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
$svc->incrementCommentsReceived($user->id);
|
||||
$svc->incrementCommentsReceived($user->id);
|
||||
$svc->decrementCommentsReceived($user->id);
|
||||
|
||||
expect((int) statsRow($user->id)->comments_received_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('incrementReactionsReceived and decrementReactionsReceived work symmetrically', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
$svc->incrementReactionsReceived($user->id, 3);
|
||||
$svc->decrementReactionsReceived($user->id, 2);
|
||||
|
||||
expect((int) statsRow($user->id)->reactions_received_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('incrementAwardsReceived and decrementAwardsReceived work symmetrically', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
$svc->incrementAwardsReceived($user->id);
|
||||
$svc->decrementAwardsReceived($user->id);
|
||||
|
||||
expect((int) statsRow($user->id)->awards_received_count)->toBe(0);
|
||||
});
|
||||
|
||||
test('incrementProfileViews increments profile_views_count', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
app(UserStatsService::class)->incrementProfileViews($user->id, 5);
|
||||
|
||||
expect((int) statsRow($user->id)->profile_views_count)->toBe(5);
|
||||
});
|
||||
|
||||
// ─── 4. Timestamps ───────────────────────────────────────────────────────────
|
||||
|
||||
test('setLastUploadAt writes the timestamp', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
$ts = now()->subHours(3);
|
||||
|
||||
app(UserStatsService::class)->setLastUploadAt($user->id, $ts);
|
||||
|
||||
$row = statsRow($user->id);
|
||||
expect($row->last_upload_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('setLastActiveAt writes the timestamp', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
|
||||
app(UserStatsService::class)->setLastActiveAt($user->id);
|
||||
|
||||
expect(statsRow($user->id)->last_active_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
// ─── 5. Observer wiring – Artwork created ────────────────────────────────────
|
||||
|
||||
test('creating an artwork increments uploads_count for its owner', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)->delete();
|
||||
|
||||
makeArtworkFor($creator);
|
||||
|
||||
expect((int) statsRow($creator->id)->uploads_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('soft-deleting an artwork decrements uploads_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
$before = (int) statsRow($creator->id)->uploads_count;
|
||||
$artwork->delete();
|
||||
|
||||
expect((int) statsRow($creator->id)->uploads_count)->toBe(max(0, $before - 1));
|
||||
});
|
||||
|
||||
// ─── 6. Observer wiring – Favourites ─────────────────────────────────────────
|
||||
|
||||
test('adding a favourite increments creator favorites_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$liker = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)
|
||||
->update(['favorites_received_count' => 0]);
|
||||
|
||||
ArtworkFavourite::create(['user_id' => $liker->id, 'artwork_id' => $artwork->id]);
|
||||
|
||||
expect((int) statsRow($creator->id)->favorites_received_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('removing a favourite decrements creator favorites_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$liker = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
$fav = ArtworkFavourite::create(['user_id' => $liker->id, 'artwork_id' => $artwork->id]);
|
||||
$after = (int) statsRow($creator->id)->favorites_received_count;
|
||||
|
||||
$fav->delete();
|
||||
|
||||
expect((int) statsRow($creator->id)->favorites_received_count)->toBe(max(0, $after - 1));
|
||||
});
|
||||
|
||||
// ─── 7. Observer wiring – Comments ───────────────────────────────────────────
|
||||
|
||||
test('adding a comment increments creator comments_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$commenter = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)
|
||||
->update(['comments_received_count' => 0]);
|
||||
|
||||
ArtworkComment::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $commenter->id,
|
||||
'content' => 'Nice work!',
|
||||
'is_approved'=> true,
|
||||
]);
|
||||
|
||||
expect((int) statsRow($creator->id)->comments_received_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('soft-deleting a comment decrements creator comments_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$commenter = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
$comment = ArtworkComment::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $commenter->id,
|
||||
'content' => 'Hi',
|
||||
'is_approved'=> true,
|
||||
]);
|
||||
$before = (int) statsRow($creator->id)->comments_received_count;
|
||||
|
||||
$comment->delete();
|
||||
|
||||
expect((int) statsRow($creator->id)->comments_received_count)->toBe(max(0, $before - 1));
|
||||
});
|
||||
|
||||
// ─── 8. Observer wiring – Reactions ──────────────────────────────────────────
|
||||
|
||||
test('adding a reaction increments creator reactions_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$reactor = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)
|
||||
->update(['reactions_received_count' => 0]);
|
||||
|
||||
ArtworkReaction::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $reactor->id,
|
||||
'reaction' => 'heart',
|
||||
]);
|
||||
|
||||
expect((int) statsRow($creator->id)->reactions_received_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('removing a reaction decrements creator reactions_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$reactor = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
$reaction = ArtworkReaction::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $reactor->id,
|
||||
'reaction' => 'thumbs_up',
|
||||
]);
|
||||
$before = (int) statsRow($creator->id)->reactions_received_count;
|
||||
|
||||
$reaction->delete();
|
||||
|
||||
expect((int) statsRow($creator->id)->reactions_received_count)->toBe(max(0, $before - 1));
|
||||
});
|
||||
|
||||
// ─── 9. Observer wiring – Awards ────────────────────────────────────────────
|
||||
|
||||
test('giving an award increments creator awards_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$awarder = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)
|
||||
->update(['awards_received_count' => 0]);
|
||||
|
||||
$svc = app(ArtworkAwardService::class);
|
||||
$svc->award($artwork, $awarder, 'gold');
|
||||
|
||||
expect((int) statsRow($creator->id)->awards_received_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('removing an award decrements creator awards_received_count', function () {
|
||||
Queue::fake();
|
||||
$creator = makeCreator();
|
||||
$awarder = makeCreator();
|
||||
$artwork = makeArtworkFor($creator);
|
||||
|
||||
$svc = app(ArtworkAwardService::class);
|
||||
$svc->award($artwork, $awarder, 'gold');
|
||||
$before = (int) statsRow($creator->id)->awards_received_count;
|
||||
|
||||
$svc->removeAward($artwork, $awarder);
|
||||
|
||||
expect((int) statsRow($creator->id)->awards_received_count)->toBe(max(0, $before - 1));
|
||||
});
|
||||
|
||||
// ─── 10. Recompute – single user ─────────────────────────────────────────────
|
||||
|
||||
test('recomputeUser rebuilds counters from source tables', function () {
|
||||
Queue::fake();
|
||||
|
||||
$creator = makeCreator();
|
||||
$fanA = makeCreator();
|
||||
$fanB = makeCreator();
|
||||
|
||||
$art1 = makeArtworkFor($creator);
|
||||
$art2 = makeArtworkFor($creator);
|
||||
|
||||
// Add 2 favourites
|
||||
ArtworkFavourite::create(['user_id' => $fanA->id, 'artwork_id' => $art1->id]);
|
||||
ArtworkFavourite::create(['user_id' => $fanB->id, 'artwork_id' => $art2->id]);
|
||||
|
||||
// Add 1 comment
|
||||
ArtworkComment::create([
|
||||
'artwork_id' => $art1->id,
|
||||
'user_id' => $fanA->id,
|
||||
'content' => 'Nice',
|
||||
'is_approved'=> true,
|
||||
]);
|
||||
|
||||
// Corrupt the stored counters to simulate drift
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)->update([
|
||||
'uploads_count' => 99,
|
||||
'favorites_received_count'=> 99,
|
||||
'comments_received_count' => 99,
|
||||
]);
|
||||
|
||||
// Recompute should restore correct values
|
||||
$svc = app(UserStatsService::class);
|
||||
$svc->recomputeUser($creator->id);
|
||||
|
||||
$row = statsRow($creator->id);
|
||||
expect((int) $row->uploads_count)->toBe(2)
|
||||
->and((int) $row->favorites_received_count)->toBe(2)
|
||||
->and((int) $row->comments_received_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('recomputeUser dry-run does not write to database', function () {
|
||||
Queue::fake();
|
||||
|
||||
$creator = makeCreator();
|
||||
makeArtworkFor($creator);
|
||||
|
||||
// Corrupt the counter
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)
|
||||
->update(['uploads_count' => 99]);
|
||||
|
||||
$svc = app(UserStatsService::class);
|
||||
$result = $svc->recomputeUser($creator->id, dryRun: true);
|
||||
|
||||
// Returned value should be correct
|
||||
expect($result['uploads_count'])->toBe(1);
|
||||
|
||||
// Nothing should have been written
|
||||
expect((int) statsRow($creator->id)->uploads_count)->toBe(99);
|
||||
});
|
||||
|
||||
// ─── 11. Recompute command ────────────────────────────────────────────────────
|
||||
|
||||
test('recompute command dry-run does not write changes', function () {
|
||||
Queue::fake();
|
||||
|
||||
$creator = makeCreator();
|
||||
makeArtworkFor($creator);
|
||||
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)
|
||||
->update(['uploads_count' => 99]);
|
||||
|
||||
$this->artisan('skinbase:recompute-user-stats', [
|
||||
'user_id' => $creator->id,
|
||||
'--dry-run'=> true,
|
||||
])->assertSuccessful();
|
||||
|
||||
expect((int) statsRow($creator->id)->uploads_count)->toBe(99);
|
||||
});
|
||||
|
||||
test('recompute command live applies correct values', function () {
|
||||
Queue::fake();
|
||||
|
||||
$creator = makeCreator();
|
||||
makeArtworkFor($creator);
|
||||
makeArtworkFor($creator);
|
||||
|
||||
DB::table('user_statistics')->where('user_id', $creator->id)
|
||||
->update(['uploads_count' => 0]);
|
||||
|
||||
$this->artisan('skinbase:recompute-user-stats', [
|
||||
'user_id' => $creator->id,
|
||||
])->assertSuccessful();
|
||||
|
||||
expect((int) statsRow($creator->id)->uploads_count)->toBe(2);
|
||||
});
|
||||
|
||||
test('recompute command --all processes all users', function () {
|
||||
Queue::fake();
|
||||
|
||||
$userA = makeCreator();
|
||||
$userB = makeCreator();
|
||||
makeArtworkFor($userA);
|
||||
|
||||
DB::table('user_statistics')
|
||||
->whereIn('user_id', [$userA->id, $userB->id])
|
||||
->update(['uploads_count' => 0]);
|
||||
|
||||
$this->artisan('skinbase:recompute-user-stats', ['--all' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
expect((int) statsRow($userA->id)->uploads_count)->toBe(1)
|
||||
->and((int) statsRow($userB->id)->uploads_count)->toBe(0);
|
||||
});
|
||||
|
||||
// ─── 12. Meilisearch – toSearchableArray ─────────────────────────────────────
|
||||
|
||||
test('User toSearchableArray contains v2 stat fields', function () {
|
||||
Queue::fake();
|
||||
$user = makeCreator();
|
||||
|
||||
// Ensure stats row exists before updating
|
||||
app(UserStatsService::class)->ensureRow($user->id);
|
||||
|
||||
DB::table('user_statistics')->where('user_id', $user->id)->update([
|
||||
'uploads_count' => 10,
|
||||
'downloads_received_count' => 20,
|
||||
'artwork_views_received_count' => 30,
|
||||
'awards_received_count' => 4,
|
||||
'favorites_received_count' => 5,
|
||||
'comments_received_count' => 6,
|
||||
'reactions_received_count' => 7,
|
||||
'followers_count' => 100,
|
||||
'following_count' => 50,
|
||||
]);
|
||||
|
||||
$user->load('statistics');
|
||||
$arr = $user->toSearchableArray();
|
||||
|
||||
expect($arr)->toHaveKey('uploads_count', 10)
|
||||
->and($arr)->toHaveKey('downloads_received_count', 20)
|
||||
->and($arr)->toHaveKey('artwork_views_received_count', 30)
|
||||
->and($arr)->toHaveKey('awards_received_count', 4)
|
||||
->and($arr)->toHaveKey('favorites_received_count', 5)
|
||||
->and($arr)->toHaveKey('comments_received_count', 6)
|
||||
->and($arr)->toHaveKey('reactions_received_count', 7)
|
||||
->and($arr)->toHaveKey('followers_count', 100)
|
||||
->and($arr)->toHaveKey('following_count', 50);
|
||||
|
||||
// Old key must not be present
|
||||
expect(array_key_exists('uploads', $arr))->toBeFalse();
|
||||
});
|
||||
144
tests/Unit/ContentSanitizerTest.php
Normal file
144
tests/Unit/ContentSanitizerTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('render converts bold markdown to strong tag', function () {
|
||||
$html = ContentSanitizer::render('**bold text**');
|
||||
expect($html)->toContain('<strong>bold text</strong>');
|
||||
});
|
||||
|
||||
test('render converts italic markdown to em tag', function () {
|
||||
$html = ContentSanitizer::render('*italic text*');
|
||||
expect($html)->toContain('<em>italic text</em>');
|
||||
});
|
||||
|
||||
test('render converts inline code to code tag', function () {
|
||||
$html = ContentSanitizer::render('use `code`');
|
||||
expect($html)->toContain('<code>code</code>');
|
||||
});
|
||||
|
||||
test('render auto-links URLs', function () {
|
||||
$html = ContentSanitizer::render('visit https://example.com for more');
|
||||
expect($html)->toContain('<a');
|
||||
expect($html)->toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
test('render returns empty string for null input', function () {
|
||||
expect(ContentSanitizer::render(null))->toBe('');
|
||||
});
|
||||
|
||||
test('render returns empty string for whitespace-only input', function () {
|
||||
expect(ContentSanitizer::render(' '))->toBe('');
|
||||
});
|
||||
|
||||
// ── XSS Prevention ────────────────────────────────────────────────────────────
|
||||
|
||||
test('render strips script tags', function () {
|
||||
$html = ContentSanitizer::render('<script>alert("xss")</script>hello');
|
||||
// The <script> tag itself must be gone (cannot execute)
|
||||
expect($html)->not()->toContain('<script>');
|
||||
// The word "hello" after the script block should appear
|
||||
expect($html)->toContain('hello');
|
||||
// Text inside script is rendered as harmless plain text — acceptable
|
||||
// Critical: no executable script element exists in the output
|
||||
});
|
||||
|
||||
test('render strips iframe tags', function () {
|
||||
$html = ContentSanitizer::render('<iframe src="evil.com"></iframe>');
|
||||
expect($html)->not()->toContain('<iframe');
|
||||
});
|
||||
|
||||
test('render strips javascript: links', function () {
|
||||
$html = ContentSanitizer::render('[click me](javascript:alert(1))');
|
||||
expect($html)->not()->toContain('javascript:');
|
||||
});
|
||||
|
||||
test('render strips style attributes', function () {
|
||||
$html = ContentSanitizer::render('<b style="color:red">red</b>');
|
||||
expect($html)->not()->toContain('style=');
|
||||
});
|
||||
|
||||
test('render strips onclick attributes', function () {
|
||||
$html = ContentSanitizer::render('<b onclick="evil()">text</b>');
|
||||
expect($html)->not()->toContain('onclick');
|
||||
});
|
||||
|
||||
test('render adds rel=noopener to external links', function () {
|
||||
$html = ContentSanitizer::render('[link](https://example.com)');
|
||||
expect($html)->toContain('rel="noopener noreferrer nofollow"');
|
||||
});
|
||||
|
||||
// ── Legacy HTML conversion ────────────────────────────────────────────────────
|
||||
|
||||
test('render converts legacy bold HTML to markdown output', function () {
|
||||
$html = ContentSanitizer::render('<b>old bold</b>');
|
||||
expect($html)->toContain('<strong>');
|
||||
expect($html)->not()->toContain('<script>');
|
||||
});
|
||||
|
||||
test('render converts br tags to line breaks', function () {
|
||||
$html = ContentSanitizer::render("line one<br>line two");
|
||||
// Should not contain the raw <br> tag in unexpected ways
|
||||
expect($html)->toContain('line one');
|
||||
expect($html)->toContain('line two');
|
||||
});
|
||||
|
||||
// ── Plain text ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('stripToPlain removes all HTML', function () {
|
||||
$plain = ContentSanitizer::stripToPlain('<p>Hello <b>world</b>!</p>');
|
||||
expect($plain)->toBe('Hello world!');
|
||||
});
|
||||
|
||||
test('stripToPlain converts br to newline', function () {
|
||||
$plain = ContentSanitizer::stripToPlain("line one<br>line two");
|
||||
expect($plain)->toContain("\n");
|
||||
});
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('validate returns error for raw HTML tags', function () {
|
||||
$errors = ContentSanitizer::validate('<script>evil</script>');
|
||||
expect($errors)->not()->toBeEmpty();
|
||||
expect(implode(' ', $errors))->toContain('HTML');
|
||||
});
|
||||
|
||||
test('validate passes for clean markdown', function () {
|
||||
$errors = ContentSanitizer::validate('**bold** and *italic* and `code`');
|
||||
expect($errors)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('validate returns error when content is too long', function () {
|
||||
$errors = ContentSanitizer::validate(str_repeat('a', 10_001));
|
||||
expect($errors)->not()->toBeEmpty();
|
||||
});
|
||||
|
||||
test('validate returns error when emoji density exceeds threshold', function () {
|
||||
// 10 fire emoji + 4 spaces = 14 chars; emoji count = 10; density = 10/14 ≈ 0.71 > 0.40
|
||||
$floodContent = implode(' ', array_fill(0, 10, '🔥'));
|
||||
$errors = ContentSanitizer::validate($floodContent);
|
||||
expect($errors)->not()->toBeEmpty();
|
||||
expect(implode(' ', $errors))->toContain('emoji');
|
||||
});
|
||||
|
||||
test('validate accepts content with reasonable emoji usage', function () {
|
||||
// 3 emoji in a 50-char string — density ≈ 0.06, well below threshold
|
||||
$errors = ContentSanitizer::validate('Great work on this piece 🎨 love the colours ❤️ keep it up 👏');
|
||||
expect($errors)->toBeEmpty();
|
||||
});
|
||||
|
||||
// ── collapseFlood ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('collapseFlood delegates to LegacySmileyMapper and collapses runs', function () {
|
||||
$input = implode(' ', array_fill(0, 8, '🍺'));
|
||||
$result = ContentSanitizer::collapseFlood($input);
|
||||
expect($result)->toContain('×8');
|
||||
expect(substr_count($result, '🍺'))->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('collapseFlood returns unchanged string when no flood present', function () {
|
||||
$input = 'Nice art 🎨 love it ❤️';
|
||||
expect(ContentSanitizer::collapseFlood($input))->toBe($input);
|
||||
});
|
||||
110
tests/Unit/LegacySmileyMapperTest.php
Normal file
110
tests/Unit/LegacySmileyMapperTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Services\LegacySmileyMapper;
|
||||
|
||||
test('convert beer smiley to emoji', function () {
|
||||
$result = LegacySmileyMapper::convert('great work :beer');
|
||||
expect($result)->toContain('🍺');
|
||||
expect($result)->not()->toContain(':beer');
|
||||
});
|
||||
|
||||
test('convert multiple smileys in one string', function () {
|
||||
$result = LegacySmileyMapper::convert(':clap great art :love it');
|
||||
expect($result)->toContain('👏');
|
||||
expect($result)->toContain('❤️');
|
||||
});
|
||||
|
||||
test('does not replace smiley codes embedded in words', function () {
|
||||
// ":lol" should only be replaced when it stands alone
|
||||
$result = LegacySmileyMapper::convert('something:lol mixed');
|
||||
// The code is embedded in a word, so it should NOT be replaced
|
||||
expect($result)->toContain(':lol');
|
||||
});
|
||||
|
||||
test('detect returns found codes', function () {
|
||||
$found = LegacySmileyMapper::detect('nice :beer and :lol');
|
||||
expect($found)->toContain(':beer');
|
||||
expect($found)->toContain(':lol');
|
||||
});
|
||||
|
||||
test('detect returns empty array for clean text', function () {
|
||||
$found = LegacySmileyMapper::detect('hello world, no smileys here');
|
||||
expect($found)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('convert returns original string when no codes present', function () {
|
||||
$input = 'This has no smiley codes.';
|
||||
$result = LegacySmileyMapper::convert($input);
|
||||
expect($result)->toBe($input);
|
||||
});
|
||||
|
||||
test('getMap returns non-empty array', function () {
|
||||
$map = LegacySmileyMapper::getMap();
|
||||
expect($map)->toBeArray()->not()->toBeEmpty();
|
||||
expect($map[':beer'])->toBe('🍺');
|
||||
});
|
||||
|
||||
test('convert handles empty string', function () {
|
||||
expect(LegacySmileyMapper::convert(''))->toBe('');
|
||||
});
|
||||
|
||||
// ── collapseFlood ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('collapseFlood leaves short runs untouched', function () {
|
||||
// 5 beer mugs with spaces — at or below the maxRun=5 default, no collapse.
|
||||
$input = '\ud83c\udf7a \ud83c\udf7a \ud83c\udf7a \ud83c\udf7a \ud83c\udf7a';
|
||||
expect(LegacySmileyMapper::collapseFlood($input))->toBe($input);
|
||||
});
|
||||
|
||||
test('collapseFlood collapses a run-together flood', function () {
|
||||
// 8 fire emoji in a row (no spaces) — should produce 5 + ×8
|
||||
$input = str_repeat('🔥', 8);
|
||||
$result = LegacySmileyMapper::collapseFlood($input);
|
||||
expect($result)->toContain('×8');
|
||||
// output has at most maxRun (5) copies of the emoji
|
||||
$count = substr_count($result, '🔥');
|
||||
expect($count)->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('collapseFlood collapses a space-separated flood', function () {
|
||||
// 10 clap emoji separated by single spaces
|
||||
$input = implode(' ', array_fill(0, 10, '👏'));
|
||||
$result = LegacySmileyMapper::collapseFlood($input);
|
||||
expect($result)->toContain('×10');
|
||||
$count = substr_count($result, '👏');
|
||||
expect($count)->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('collapseFlood respects custom maxRun', function () {
|
||||
$input = implode(' ', array_fill(0, 8, '❤️'));
|
||||
$result = LegacySmileyMapper::collapseFlood($input, maxRun: 3);
|
||||
expect($result)->toContain('×8');
|
||||
$count = substr_count($result, '❤️');
|
||||
expect($count)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('collapseFlood does not affect regular text or mixed content', function () {
|
||||
$input = 'Nice work \ud83d\udc4d really cool \ud83d\udd25';
|
||||
$result = LegacySmileyMapper::collapseFlood($input);
|
||||
expect($result)->toBe($input); // nothing to collapse
|
||||
});
|
||||
|
||||
test('collapseFlood handles empty string', function () {
|
||||
expect(LegacySmileyMapper::collapseFlood(''))->toBe('');
|
||||
});
|
||||
|
||||
// ── detect() colon-lookahead regression ──────────────────────────────────────
|
||||
|
||||
test('detect does not match smiley code with trailing colon', function () {
|
||||
// ':sad:' used to partially match ':sad' leaving a stray ':'.
|
||||
// After the fix, ':' is not in the lookahead, so ':sad:' should be
|
||||
// detected only if followed by whitespace / punctuation (not ':').
|
||||
// A bare ':sad:' surrounded by spaces must still be detected.
|
||||
$found = LegacySmileyMapper::detect(':sad and more text');
|
||||
expect($found)->toContain(':sad');
|
||||
|
||||
// ':sad:' where the colon immediately follows should NOT be detected
|
||||
// because ':' is no longer in [.,!?;] lookahead.
|
||||
$foundColon = LegacySmileyMapper::detect('text:sad: more');
|
||||
expect($foundColon)->not()->toContain(':sad');
|
||||
});
|
||||
@@ -3,5 +3,5 @@ import { test, expect } from '@playwright/test';
|
||||
test('home page loads and shows legacy page container', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/Skinbase/i);
|
||||
await expect(page.locator('.legacy-page')).toBeVisible();
|
||||
await expect(page.locator('#homepage-root')).toBeVisible();
|
||||
});
|
||||
|
||||
112
tests/e2e/messaging.spec.ts
Normal file
112
tests/e2e/messaging.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
type Fixture = {
|
||||
email: string
|
||||
password: string
|
||||
conversation_id: number
|
||||
}
|
||||
|
||||
function seedMessagingFixture(): Fixture {
|
||||
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
|
||||
const ownerEmail = `e2e-messages-owner-${token}@example.test`
|
||||
const peerEmail = `e2e-messages-peer-${token}@example.test`
|
||||
const ownerUsername = `e2eo${token}`.slice(0, 20)
|
||||
const peerUsername = `e2ep${token}`.slice(0, 20)
|
||||
|
||||
const script = [
|
||||
"use App\\Models\\User;",
|
||||
"use App\\Models\\Conversation;",
|
||||
"use App\\Models\\ConversationParticipant;",
|
||||
"use App\\Models\\Message;",
|
||||
"use Illuminate\\Support\\Facades\\Hash;",
|
||||
"use Illuminate\\Support\\Carbon;",
|
||||
`$owner = User::updateOrCreate(['email' => '${ownerEmail}'], [`,
|
||||
" 'name' => 'E2E Owner',",
|
||||
` 'username' => '${ownerUsername}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
"]);",
|
||||
`$peer = User::updateOrCreate(['email' => '${peerEmail}'], [`,
|
||||
" 'name' => 'E2E Peer',",
|
||||
` 'username' => '${peerUsername}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
"]);",
|
||||
"$conversation = Conversation::create(['type' => 'direct', 'created_by' => $owner->id]);",
|
||||
"ConversationParticipant::insert([",
|
||||
" ['conversation_id' => $conversation->id, 'user_id' => $owner->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],",
|
||||
" ['conversation_id' => $conversation->id, 'user_id' => $peer->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],",
|
||||
"]);",
|
||||
"$first = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $peer->id, 'body' => 'Seed hello']);",
|
||||
"$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $owner->id, 'body' => 'Seed latest from owner']);",
|
||||
"$conversation->update(['last_message_at' => $last->created_at]);",
|
||||
"ConversationParticipant::where('conversation_id', $conversation->id)->where('user_id', $peer->id)->update(['last_read_at' => Carbon::parse($last->created_at)->addSeconds(15)]);",
|
||||
"echo json_encode(['email' => $owner->email, 'password' => 'password', 'conversation_id' => $conversation->id]);",
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
|
||||
if (!jsonLine) {
|
||||
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
|
||||
}
|
||||
|
||||
return JSON.parse(jsonLine) as Fixture
|
||||
}
|
||||
|
||||
async function login(page: Parameters<typeof test>[0]['page'], fixture: Fixture) {
|
||||
await page.goto('/login')
|
||||
await page.locator('input[name="email"]').fill(fixture.email)
|
||||
await page.locator('input[name="password"]').fill(fixture.password)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
await page.waitForURL(/\/dashboard/)
|
||||
}
|
||||
|
||||
test.describe('Messaging UI', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: Fixture
|
||||
|
||||
test.beforeAll(() => {
|
||||
fixture = seedMessagingFixture()
|
||||
})
|
||||
|
||||
test('restores draft from localStorage on reload', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto(`/messages/${fixture.conversation_id}`)
|
||||
|
||||
const textarea = page.locator('textarea[placeholder^="Write a message"]')
|
||||
const draft = 'E2E draft should survive reload'
|
||||
|
||||
await textarea.fill(draft)
|
||||
await expect(textarea).toHaveValue(draft)
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(textarea).toHaveValue(draft)
|
||||
|
||||
const stored = await page.evaluate((key) => window.localStorage.getItem(key), `nova_draft_${fixture.conversation_id}`)
|
||||
expect(stored).toBe(draft)
|
||||
})
|
||||
|
||||
test('shows seen indicator on latest direct message from current user', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto(`/messages/${fixture.conversation_id}`)
|
||||
|
||||
await expect(page.locator('text=Seed latest from owner')).toBeVisible()
|
||||
await expect(page.locator('text=/^Seen\\s.+\\sago$/')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -67,13 +67,13 @@ const PUBLIC_ROUTES: RouteFixture[] = [
|
||||
{ url: '/comments/latest', label: 'Latest comments (new)' },
|
||||
{ url: '/comments/monthly', label: 'Monthly commentators (new)' },
|
||||
{ url: '/downloads/today', label: 'Today downloads (new)' },
|
||||
{ url: '/top-authors', label: 'Top authors (legacy)' },
|
||||
{ url: '/top-authors', label: 'Top authors (legacy)', expectUrlContains: '/creators/top' },
|
||||
{ url: '/top-favourites', label: 'Top favourites (legacy)' },
|
||||
{ url: '/today-downloads', label: 'Today downloads (legacy)' },
|
||||
{ url: '/today-in-history', label: 'Today in history' },
|
||||
{ url: '/monthly-commentators',label: 'Monthly commentators (legacy)' },
|
||||
{ url: '/latest-comments', label: 'Latest comments (legacy)' },
|
||||
{ url: '/interviews', label: 'Interviews' },
|
||||
{ url: '/interviews', label: 'Interviews', expectUrlContains: '/stories' },
|
||||
{ url: '/chat', label: 'Chat' },
|
||||
|
||||
// ── Forum ────────────────────────────────────────────────────────────────
|
||||
@@ -84,6 +84,21 @@ const PUBLIC_ROUTES: RouteFixture[] = [
|
||||
{ url: '/wallpapers', label: 'Wallpapers root' },
|
||||
{ url: '/skins', label: 'Skins root' },
|
||||
|
||||
// ── Discover ──────────────────────────────────────────────────────────────
|
||||
{ url: '/discover/trending', label: 'Discover: Trending' },
|
||||
{ url: '/discover/fresh', label: 'Discover: Fresh' },
|
||||
{ url: '/discover/top-rated', label: 'Discover: Top Rated' },
|
||||
{ url: '/discover/most-downloaded', label: 'Discover: Most Downloaded' },
|
||||
{ url: '/discover/on-this-day', label: 'Discover: On This Day' },
|
||||
|
||||
// ── Creators ──────────────────────────────────────────────────────────────
|
||||
{ url: '/creators/top', label: 'Creators: Top' },
|
||||
{ url: '/creators/rising', label: 'Creators: Rising' },
|
||||
{ url: '/stories', label: 'Creator Stories' },
|
||||
|
||||
// ── Tags ──────────────────────────────────────────────────────────────────
|
||||
{ url: '/tags', label: 'Tags index' },
|
||||
|
||||
// ── Auth pages (guest-only, publicly accessible) ─────────────────────────
|
||||
{ url: '/login', label: 'Login page' },
|
||||
{ url: '/register', label: 'Register page' },
|
||||
@@ -103,6 +118,7 @@ const AUTH_ROUTES: RouteFixture[] = [
|
||||
{ url: '/mybuddies', label: 'My buddies', requiresAuth: true },
|
||||
{ url: '/buddies', label: 'Buddies', requiresAuth: true },
|
||||
{ url: '/manage', label: 'Manage', requiresAuth: true },
|
||||
{ url: '/dashboard/awards', label: 'Dashboard awards', requiresAuth: true },
|
||||
];
|
||||
|
||||
// Routes that should 404 (to ensure 404 handling is clean and doesn't 500)
|
||||
@@ -304,7 +320,7 @@ test.describe('Landmark spot-checks', () => {
|
||||
test('Home page — has gallery section', async ({ page }) => {
|
||||
const probe = attachProbes(page);
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('[data-nova-gallery], .gallery-grid, .container_photo')).toBeVisible();
|
||||
await expect(page.locator('#homepage-root section').first()).toBeVisible();
|
||||
expectCleanProbe(probe);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user