diff --git a/app/Models/LiveStream.php b/app/Models/LiveStream.php index fd579e6..ce0d857 100644 --- a/app/Models/LiveStream.php +++ b/app/Models/LiveStream.php @@ -124,7 +124,15 @@ class LiveStream extends Model return true; } - return $this->isLive() && in_array($this->visibility, ['public', 'unlisted'], true); + // Visibility first — private streams stay owner-only no matter what. + if (! in_array($this->visibility, ['public', 'unlisted'], true)) { + return false; + } + + // Live streams are watchable while they're on; ended streams remain + // permalinked so viewers see "Stream ended" and the replay CTA + // instead of a hard 404. + return $this->isLive() || $this->status === self::STATUS_ENDED; } /* ---------------- Publish credential ---------------- */ diff --git a/resources/views/live/watch.blade.php b/resources/views/live/watch.blade.php index 5114efa..aa5c24a 100644 --- a/resources/views/live/watch.blade.php +++ b/resources/views/live/watch.blade.php @@ -291,6 +291,7 @@ @endsection @section('scripts') +@unless($isEnded) +@endunless @endsection diff --git a/tests/Feature/LivePolishTest.php b/tests/Feature/LivePolishTest.php new file mode 100644 index 0000000..e9954ea --- /dev/null +++ b/tests/Feature/LivePolishTest.php @@ -0,0 +1,158 @@ +withoutMiddleware([ + VerifyCsrfToken::class, + \Illuminate\Session\Middleware\AuthenticateSession::class, + ]); + } + + // ─── Private visibility accepted ───────────────── + + public function test_create_live_stream_accepts_private_visibility(): void + { + $u = $this->makeUser(); + $this->actingAs($u); + + $this->post(route('live.store'), [ + 'title' => 'My private test stream', + 'description' => 'just testing', + 'visibility' => 'private', + ])->assertRedirect(); + + $this->assertDatabaseHas('livestreams', [ + 'user_id' => $u->id, + 'title' => 'My private test stream', + 'visibility' => 'private', + ]); + } + + // ─── Ended state renders "Stream ended" ────────── + + public function test_ended_stream_watch_page_shows_stream_ended(): void + { + $u = $this->makeUser(); + $s = $this->makeStream($u, [ + 'status' => LiveStream::STATUS_ENDED, + 'started_at' => now()->subMinutes(10), + 'ended_at' => now(), + ]); + + $html = $this->get(route('live.watch', $s))->assertOk()->getContent(); + $this->assertStringContainsString('Stream ended', $html); + $this->assertStringNotContainsString('Waiting for the broadcast to start', $html); + } + + // ─── Channel name is a link ────────────────────── + + public function test_watch_page_channel_name_is_a_real_link(): void + { + $u = $this->makeUser(); + $s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]); + $expected = route('channel', $u->channel); + + $html = $this->get(route('live.watch', $s))->assertOk()->getContent(); + + $this->assertStringContainsString('href="' . $expected . '"', $html); + } + + // ─── Chat poll ─────────────────────────────────── + + public function test_chat_poll_returns_messages_newer_than_since(): void + { + $u = $this->makeUser(); + $s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]); + + $a = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'hello', 'created_at' => now()]); + $b = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'world', 'created_at' => now()]); + + $this->getJson(route('live.chat.poll', $s) . '?since=' . $a->id) + ->assertOk() + ->assertJsonCount(1, 'messages') + ->assertJsonPath('messages.0.body', 'world'); + } + + // ─── Chat send ─────────────────────────────────── + + public function test_chat_send_rejects_guests(): void + { + $u = $this->makeUser(); + $s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]); + + // postJson sets Accept: application/json so the auth middleware + // returns 401 instead of redirecting to login. + $this->postJson(route('live.chat.send', $s), ['body' => 'hi']) + ->assertStatus(401); + } + + public function test_chat_send_rejects_offline_streams(): void + { + $u = $this->makeUser(); + $s = $this->makeStream($u, ['status' => LiveStream::STATUS_ENDED]); + $this->actingAs($u); + + $this->postJson(route('live.chat.send', $s), ['body' => 'hi']) + ->assertStatus(422); + } + + public function test_private_stream_chat_rejects_non_owner(): void + { + $owner = $this->makeUser(); + $viewer = $this->makeUser(); + $s = $this->makeStream($owner, ['status' => LiveStream::STATUS_LIVE, 'visibility' => 'private']); + $this->actingAs($viewer); + + $this->postJson(route('live.chat.send', $s), ['body' => 'sneak']) + ->assertStatus(404); + } + + // ─── Helpers ───────────────────────────────────── + + private function makeUser(): User + { + return User::factory()->create([ + 'username' => 'lu'.substr(bin2hex(random_bytes(4)), 0, 6), + ]); + } + + private function makeStream(User $u, array $attrs = []): LiveStream + { + return LiveStream::create(array_merge([ + 'user_id' => $u->id, + 'public_id' => substr(bin2hex(random_bytes(10)), 0, 16), + 'title' => 'Test stream', + 'description' => '', + 'status' => LiveStream::STATUS_IDLE, + 'visibility' => 'public', + ], $attrs)); + } +}