Users can now upload an avatar from Profile settings.
This commit is contained in:
@@ -81,6 +81,7 @@ class ChannelController extends Controller
|
|||||||
'user' => [
|
'user' => [
|
||||||
'id' => $message->user->id,
|
'id' => $message->user->id,
|
||||||
'name' => $message->user->name,
|
'name' => $message->user->name,
|
||||||
|
'avatar_url' => $message->user->avatar,
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
'isFollowing' => $request->user()
|
'isFollowing' => $request->user()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -30,13 +31,26 @@ class ProfileController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->user()->fill($request->validated());
|
$validated = $request->validated();
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
if ($request->user()->isDirty('email')) {
|
if ($request->hasFile('avatar')) {
|
||||||
$request->user()->email_verified_at = null;
|
if ($user->avatar_path) {
|
||||||
|
Storage::disk('public')->delete($user->avatar_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->user()->save();
|
$validated['avatar_path'] = $request->file('avatar')->store("users/{$user->id}/avatars", 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($validated['avatar']);
|
||||||
|
|
||||||
|
$user->fill($validated);
|
||||||
|
|
||||||
|
if ($user->isDirty('email')) {
|
||||||
|
$user->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
|
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
|
||||||
|
|
||||||
@@ -52,6 +66,10 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
|
|
||||||
|
if ($user->avatar_path) {
|
||||||
|
Storage::disk('public')->delete($user->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
$user->delete();
|
$user->delete();
|
||||||
|
|
||||||
$request->session()->invalidate();
|
$request->session()->invalidate();
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return $this->profileRules($this->user()->id);
|
return [
|
||||||
|
...$this->profileRules($this->user()->id),
|
||||||
|
'avatar' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ namespace App\Models;
|
|||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'is_admin', 'can_create_channel', 'suspended_at'])]
|
#[Fillable(['name', 'email', 'password', 'avatar_path', 'is_admin', 'can_create_channel', 'suspended_at'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'avatar_path', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
@@ -24,6 +26,10 @@ class User extends Authenticatable
|
|||||||
'can_create_channel' => false,
|
'can_create_channel' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'avatar',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
@@ -60,4 +66,12 @@ class User extends Authenticatable
|
|||||||
{
|
{
|
||||||
return $this->can_create_channel || PlatformSetting::current()->channel_creation_open;
|
return $this->can_create_channel || PlatformSetting::current()->channel_creation_open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Attribute<string|null, never>
|
||||||
|
*/
|
||||||
|
protected function avatar(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(fn (): ?string => $this->avatar_path ? Storage::url($this->avatar_path) : null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('avatar_path')->nullable()->after('password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('avatar_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -199,7 +199,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
>
|
>
|
||||||
<Avatar className="size-8 overflow-hidden rounded-full">
|
<Avatar className="size-8 overflow-hidden rounded-full">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={auth.user?.avatar}
|
src={auth.user?.avatar ?? undefined}
|
||||||
alt={auth.user?.name}
|
alt={auth.user?.name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="rounded-md bg-secondary text-secondary-foreground">
|
<AvatarFallback className="rounded-md bg-secondary text-secondary-foreground">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function UserInfo({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Avatar className="h-8 w-8 overflow-hidden rounded-full">
|
<Avatar className="h-8 w-8 overflow-hidden rounded-full">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarImage src={user.avatar ?? undefined} alt={user.name} />
|
||||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||||
{getInitials(user.name)}
|
{getInitials(user.name)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar as ChatAvatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +25,7 @@ import {
|
|||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { VideoPlayer } from '@/components/video-player';
|
import { VideoPlayer } from '@/components/video-player';
|
||||||
|
import { useInitials } from '@/hooks/use-initials';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { home, login } from '@/routes';
|
import { home, login } from '@/routes';
|
||||||
import { follow, unfollow } from '@/routes/channels';
|
import { follow, unfollow } from '@/routes/channels';
|
||||||
@@ -278,6 +284,8 @@ function ChatPanel({
|
|||||||
onSubmit: (event: FormEvent) => void;
|
onSubmit: (event: FormEvent) => void;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const getInitials = useInitials();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 w-full flex-col">
|
<div className="flex min-h-0 w-full flex-col">
|
||||||
<div
|
<div
|
||||||
@@ -294,11 +302,28 @@ function ChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<div key={message.id} className="text-sm">
|
<div
|
||||||
<span className="font-medium">{message.user.name}</span>
|
key={message.id}
|
||||||
<span className="ml-2 text-muted-foreground">
|
className="flex min-w-0 gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<ChatAvatar className="mt-0.5 size-7 rounded-md">
|
||||||
|
<AvatarImage
|
||||||
|
src={message.user.avatar_url ?? undefined}
|
||||||
|
alt={message.user.name}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="rounded-md bg-secondary text-[11px] font-semibold text-secondary-foreground">
|
||||||
|
{getInitials(message.user.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</ChatAvatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
{message.user.name}
|
||||||
|
</div>
|
||||||
|
<div className="break-words text-muted-foreground">
|
||||||
{message.body}
|
{message.body}
|
||||||
</span>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||||
|
import { ImagePlus } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import DeleteUser from '@/components/delete-user';
|
import DeleteUser from '@/components/delete-user';
|
||||||
import Heading from '@/components/heading';
|
import Heading from '@/components/heading';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useInitials } from '@/hooks/use-initials';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/profile';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
|
|
||||||
@@ -18,11 +22,34 @@ export default function Profile({
|
|||||||
}) {
|
}) {
|
||||||
const { auth } = usePage().props;
|
const { auth } = usePage().props;
|
||||||
const user = auth.user;
|
const user = auth.user;
|
||||||
|
const getInitials = useInitials();
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (avatarPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(avatarPreviewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [avatarPreviewUrl]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewAvatar(file: File | null) {
|
||||||
|
setAvatarPreviewUrl((currentUrl) => {
|
||||||
|
if (currentUrl) {
|
||||||
|
URL.revokeObjectURL(currentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return file ? URL.createObjectURL(file) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Profile settings" />
|
<Head title="Profile settings" />
|
||||||
@@ -33,7 +60,7 @@ export default function Profile({
|
|||||||
<Heading
|
<Heading
|
||||||
variant="small"
|
variant="small"
|
||||||
title="Profile information"
|
title="Profile information"
|
||||||
description="Update your name and email address"
|
description="Update your name, email address, and avatar"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -41,10 +68,66 @@ export default function Profile({
|
|||||||
options={{
|
options={{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
}}
|
}}
|
||||||
|
resetOnSuccess={['avatar']}
|
||||||
|
onSuccess={() => {
|
||||||
|
setAvatarPreviewUrl(null);
|
||||||
|
|
||||||
|
if (avatarInputRef.current) {
|
||||||
|
avatarInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
encType="multipart/form-data"
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{({ processing, errors }) => (
|
{({ processing, progress, errors }) => (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col gap-4 rounded-md border bg-card p-4 sm:flex-row sm:items-center">
|
||||||
|
<Avatar className="size-20 rounded-md">
|
||||||
|
<AvatarImage
|
||||||
|
src={
|
||||||
|
avatarPreviewUrl ??
|
||||||
|
user.avatar ??
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
alt={user.name}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="rounded-md bg-secondary text-lg font-semibold text-secondary-foreground">
|
||||||
|
{getInitials(user.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 flex-1 gap-2">
|
||||||
|
<Label htmlFor="avatar">Avatar</Label>
|
||||||
|
<Input
|
||||||
|
id="avatar"
|
||||||
|
ref={avatarInputRef}
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={(event) =>
|
||||||
|
previewAvatar(
|
||||||
|
event.target.files?.[0] ?? null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<ImagePlus className="size-3.5" />
|
||||||
|
<span>
|
||||||
|
JPG, PNG, or WebP up to 2 MB.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{progress && (
|
||||||
|
<progress
|
||||||
|
value={progress.percentage}
|
||||||
|
max="100"
|
||||||
|
className="h-1.5 w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InputError message={errors.avatar} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">Name</Label>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type User = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar?: string;
|
avatar: string | null;
|
||||||
email_verified_at: string | null;
|
email_verified_at: string | null;
|
||||||
two_factor_enabled?: boolean;
|
two_factor_enabled?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -72,5 +72,6 @@ export type ChatMessage = {
|
|||||||
user: {
|
user: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace Tests\Feature\Settings;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ProfileUpdateTest extends TestCase
|
class ProfileUpdateTest extends TestCase
|
||||||
@@ -21,6 +24,22 @@ class ProfileUpdateTest extends TestCase
|
|||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_profile_page_exposes_user_avatar_url(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'avatar_path' => 'users/demo/avatar.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this
|
||||||
|
->actingAs($user)
|
||||||
|
->get(route('profile.edit'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('settings/profile')
|
||||||
|
->where('auth.user.avatar', '/storage/users/demo/avatar.jpg'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_profile_information_can_be_updated()
|
public function test_profile_information_can_be_updated()
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -61,9 +80,67 @@ class ProfileUpdateTest extends TestCase
|
|||||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_user_can_update_their_avatar(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'avatar_path' => 'users/demo/old-avatar.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Storage::disk('public')->put($user->avatar_path, 'old avatar');
|
||||||
|
|
||||||
|
$this
|
||||||
|
->actingAs($user)
|
||||||
|
->post(route('profile.update'), [
|
||||||
|
'_method' => 'PATCH',
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'avatar' => UploadedFile::fake()->image('avatar.png', 400, 400),
|
||||||
|
])
|
||||||
|
->assertRedirect(route('profile.edit'))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertNotSame('users/demo/old-avatar.jpg', $user->avatar_path);
|
||||||
|
$this->assertStringStartsWith("users/{$user->id}/avatars/", $user->avatar_path);
|
||||||
|
Storage::disk('public')->assertMissing('users/demo/old-avatar.jpg');
|
||||||
|
Storage::disk('public')->assertExists($user->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_avatar_must_be_an_image(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'avatar_path' => 'users/demo/current-avatar.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this
|
||||||
|
->actingAs($user)
|
||||||
|
->from(route('profile.edit'))
|
||||||
|
->post(route('profile.update'), [
|
||||||
|
'_method' => 'PATCH',
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'avatar' => UploadedFile::fake()->create('avatar.txt', 8, 'text/plain'),
|
||||||
|
])
|
||||||
|
->assertRedirect(route('profile.edit'))
|
||||||
|
->assertSessionHasErrors('avatar');
|
||||||
|
|
||||||
|
$this->assertSame('users/demo/current-avatar.jpg', $user->refresh()->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_user_can_delete_their_account()
|
public function test_user_can_delete_their_account()
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'avatar_path' => 'users/demo/avatar.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Storage::disk('public')->put($user->avatar_path, 'avatar');
|
||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
@@ -77,6 +154,7 @@ class ProfileUpdateTest extends TestCase
|
|||||||
|
|
||||||
$this->assertGuest();
|
$this->assertGuest();
|
||||||
$this->assertNull($user->fresh());
|
$this->assertNull($user->fresh());
|
||||||
|
Storage::disk('public')->assertMissing('users/demo/avatar.jpg');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_correct_password_must_be_provided_to_delete_account()
|
public function test_correct_password_must_be_provided_to_delete_account()
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class ViewerInteractionTest extends TestCase
|
|||||||
|
|
||||||
public function test_authenticated_user_can_chat_while_channel_is_live(): void
|
public function test_authenticated_user_can_chat_while_channel_is_live(): void
|
||||||
{
|
{
|
||||||
$viewer = User::factory()->create();
|
$viewer = User::factory()->create([
|
||||||
|
'avatar_path' => 'users/demo/avatar.jpg',
|
||||||
|
]);
|
||||||
$channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]);
|
$channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]);
|
||||||
$broadcast = Broadcast::factory()->for($channel)->live()->create();
|
$broadcast = Broadcast::factory()->for($channel)->live()->create();
|
||||||
$channel->forceFill(['live_broadcast_id' => $broadcast->id])->save();
|
$channel->forceFill(['live_broadcast_id' => $broadcast->id])->save();
|
||||||
@@ -47,6 +49,14 @@ class ViewerInteractionTest extends TestCase
|
|||||||
'user_id' => $viewer->id,
|
'user_id' => $viewer->id,
|
||||||
'body' => 'Hello chat',
|
'body' => 'Hello chat',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(route('channels.show', $channel))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('channels/show')
|
||||||
|
->where('chatMessages.0.user.avatar_url', '/storage/users/demo/avatar.jpg'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_anonymous_users_can_watch_channel_page_but_not_chat(): void
|
public function test_anonymous_users_can_watch_channel_page_but_not_chat(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user