Files
SkinbaseNova/tests/Feature/Posts/PostFeedTest.php
Gregor Klevze dc51d65440 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
2026-03-03 09:48:31 +01:00

242 lines
10 KiB
PHP

<?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);
});