feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
This commit is contained in:
@@ -35,11 +35,9 @@ class DashboardFavoritesTest extends TestCase
|
||||
|
||||
$html = $response->getContent();
|
||||
$this->assertNotFalse($html);
|
||||
$this->assertStringContainsString('itemprop="thumbnailUrl"', $html);
|
||||
$this->assertStringContainsString('data-blur-preview', $html);
|
||||
$this->assertStringContainsString('loading="lazy"', $html);
|
||||
$this->assertStringContainsString('decoding="async"', $html);
|
||||
$this->assertMatchesRegularExpression('/<img[^>]*data-blur-preview[^>]*/i', $html);
|
||||
$this->assertStringContainsString('data-react-masonry-gallery', $html);
|
||||
$this->assertStringContainsString('data-artworks=', $html);
|
||||
$this->assertStringContainsString('data-gallery-type="dashboard-favorites"', $html);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('dashboard.favorites.destroy', ['artwork' => $art->id]))
|
||||
|
||||
241
tests/Feature/Posts/PostFeedTest.php
Normal file
241
tests/Feature/Posts/PostFeedTest.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostShareService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
// ── SQLite polyfill + helpers ──────────────────────────────────────────────────
|
||||
|
||||
function postTestArtwork(array $attrs = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(fn () => Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
...$attrs,
|
||||
]));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) {
|
||||
return max($args);
|
||||
}, -1);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Post visibility scopes ─────────────────────────────────────────────────────
|
||||
|
||||
test('public post is visible to everyone', function () {
|
||||
$author = User::factory()->create();
|
||||
$post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'public']);
|
||||
|
||||
expect(Post::visibleTo(null)->find($post->id))->not->toBeNull();
|
||||
expect(Post::visibleTo(999)->find($post->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('followers-only post is hidden from non-followers', function () {
|
||||
$author = User::factory()->create();
|
||||
$stranger = User::factory()->create();
|
||||
$post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'followers']);
|
||||
|
||||
expect(Post::visibleTo($stranger->id)->find($post->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('followers-only post is visible to followers', function () {
|
||||
$author = User::factory()->create();
|
||||
$follower = User::factory()->create();
|
||||
DB::table('user_followers')->insert([
|
||||
'user_id' => $author->id,
|
||||
'follower_id' => $follower->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'followers']);
|
||||
|
||||
expect(Post::visibleTo($follower->id)->find($post->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('private post is only visible to the author', function () {
|
||||
$author = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'private']);
|
||||
|
||||
expect(Post::visibleTo(null)->find($post->id))->toBeNull();
|
||||
expect(Post::visibleTo($other->id)->find($post->id))->toBeNull();
|
||||
expect(Post::visibleTo($author->id)->find($post->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
// ── PostShareService ───────────────────────────────────────────────────────────
|
||||
|
||||
test('shareArtwork creates a post with a target', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = postTestArtwork(['user_id' => $user->id]);
|
||||
|
||||
$service = app(PostShareService::class);
|
||||
$post = $service->shareArtwork($user, $artwork, 'Cool piece!', 'public');
|
||||
|
||||
expect($post)->toBeInstanceOf(Post::class)
|
||||
->and($post->type)->toBe('artwork_share')
|
||||
->and($post->body)->toBe('<p>Cool piece!</p>')
|
||||
->and($post->targets()->where('target_type', 'artwork')->where('target_id', $artwork->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('shareArtwork throws when sharing same artwork within 24 hours', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = postTestArtwork(['user_id' => $user->id]);
|
||||
|
||||
$service = app(PostShareService::class);
|
||||
$service->shareArtwork($user, $artwork, '', 'public');
|
||||
|
||||
expect(fn () => $service->shareArtwork($user, $artwork, 'Again', 'public'))
|
||||
->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('shareArtwork rejects private or unapproved artwork', function () {
|
||||
$user = User::factory()->create();
|
||||
$private = postTestArtwork(['user_id' => $user->id, 'is_public' => false, 'is_approved' => true]);
|
||||
|
||||
$service = app(PostShareService::class);
|
||||
expect(fn () => $service->shareArtwork($user, $private, '', 'public'))
|
||||
->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
// ── Profile feed API ───────────────────────────────────────────────────────────
|
||||
|
||||
test('GET /api/posts/profile/{username} returns paginated public posts', function () {
|
||||
$author = User::factory()->create();
|
||||
Post::factory()->count(3)->create(['user_id' => $author->id, 'visibility' => 'public']);
|
||||
Post::factory()->create(['user_id' => $author->id, 'visibility' => 'private']);
|
||||
|
||||
$response = $this->getJson("/api/posts/profile/{$author->username}");
|
||||
$response->assertOk()->assertJsonCount(3, 'data');
|
||||
});
|
||||
|
||||
test('GET /api/posts/profile/{username} hides followers-only posts from strangers', function () {
|
||||
$author = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
Post::factory()->count(2)->create(['user_id' => $author->id, 'visibility' => 'followers']);
|
||||
Post::factory()->count(1)->create(['user_id' => $author->id, 'visibility' => 'public']);
|
||||
|
||||
$response = $this->actingAs($viewer)->getJson("/api/posts/profile/{$author->username}");
|
||||
$response->assertOk()->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
// ── Following feed API ─────────────────────────────────────────────────────────
|
||||
|
||||
test('GET /api/posts/following requires authentication', function () {
|
||||
$this->getJson('/api/posts/following')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('GET /api/posts/following returns posts from followed users only', function () {
|
||||
$viewer = User::factory()->create();
|
||||
$followed = User::factory()->create();
|
||||
$stranger = User::factory()->create();
|
||||
|
||||
DB::table('user_followers')->insert([
|
||||
'user_id' => $followed->id,
|
||||
'follower_id' => $viewer->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Post::factory()->create(['user_id' => $followed->id, 'visibility' => 'public']);
|
||||
Post::factory()->create(['user_id' => $stranger->id, 'visibility' => 'public']);
|
||||
|
||||
$response = $this->actingAs($viewer)->getJson('/api/posts/following');
|
||||
$response->assertOk();
|
||||
|
||||
$ids = collect($response->json('data'))->pluck('author.id');
|
||||
expect($ids)->each->toBe($followed->id);
|
||||
});
|
||||
|
||||
// ── Reactions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('POST /api/posts/{id}/reactions adds reaction and increments counter', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/posts/{$post->id}/reactions", ['reaction' => 'like'])
|
||||
->assertStatus(201);
|
||||
|
||||
expect($post->fresh()->reactions_count)->toBe(1);
|
||||
expect(PostReaction::where(['post_id' => $post->id, 'user_id' => $user->id])->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('DELETE /api/posts/{id}/reactions/{reaction} removes reaction and decrements counter', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public', 'reactions_count' => 1]);
|
||||
PostReaction::create(['post_id' => $post->id, 'user_id' => $user->id, 'reaction' => 'like']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->deleteJson("/api/posts/{$post->id}/reactions/like")
|
||||
->assertOk();
|
||||
|
||||
expect($post->fresh()->reactions_count)->toBe(0);
|
||||
expect(PostReaction::where(['post_id' => $post->id, 'user_id' => $user->id])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('POST /api/posts/{id}/comments creates comment and increments counter', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/posts/{$post->id}/comments", ['body' => 'Nice post!'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('comment.body', '<p>Nice post!</p>');
|
||||
|
||||
expect($post->fresh()->comments_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('GET /api/posts/{id}/comments is publicly accessible', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public']);
|
||||
|
||||
$this->getJson("/api/posts/{$post->id}/comments")
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data', 'meta']);
|
||||
});
|
||||
|
||||
// ── Diversity pass ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('PostFeedService diversity pass limits consecutive posts from same author', function () {
|
||||
$service = app(PostFeedService::class);
|
||||
|
||||
$userA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
|
||||
// 8 posts from A then 2 from B
|
||||
$posts = collect();
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$posts->push(Post::factory()->make(['user_id' => $userA->id]));
|
||||
}
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
$posts->push(Post::factory()->make(['user_id' => $userB->id]));
|
||||
}
|
||||
|
||||
$diversified = $service->applyDiversityPass($posts);
|
||||
|
||||
// After the 5th consecutive post from A, subsequent A posts should be deferred
|
||||
$runLength = 0;
|
||||
$maxRun = 0;
|
||||
$lastId = null;
|
||||
foreach ($diversified as $post) {
|
||||
if ($post->user_id === $lastId) {
|
||||
$runLength++;
|
||||
} else {
|
||||
$runLength = 1;
|
||||
$lastId = $post->user_id;
|
||||
}
|
||||
$maxRun = max($maxRun, $runLength);
|
||||
}
|
||||
|
||||
expect($maxRun)->toBeLessThanOrEqual(5);
|
||||
});
|
||||
Reference in New Issue
Block a user