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('

Cool piece!

') ->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', '

Nice post!

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