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
242 lines
10 KiB
PHP
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);
|
|
});
|