diff --git a/.env.ci b/.env.ci index f63876c..474f7fb 100644 --- a/.env.ci +++ b/.env.ci @@ -14,3 +14,7 @@ QUEUE_CONNECTION=sync SESSION_DRIVER=array SESSION_LIFETIME=120 MAIL_MAILER=array + +DB_CONNECTION=sqlite +DB_DATABASE=:memory: +DB_FOREIGN_KEYS=true diff --git a/.github/workflows/test-and-lint-php.yml b/.github/workflows/test-and-lint-php.yml index 0d1366e..e6af08d 100644 --- a/.github/workflows/test-and-lint-php.yml +++ b/.github/workflows/test-and-lint-php.yml @@ -8,7 +8,7 @@ on: - '**.php' - 'composer.json' - 'composer.lock' - - 'phpunit.xml' + - 'phpunit.xml.ci' - '.env.ci' jobs: @@ -65,4 +65,4 @@ jobs: - name: Execute tests run: | cp .env.ci .env - php artisan test --colors=always + vendor/bin/phpunit -c phpunit.xml.ci --colors=always diff --git a/Taskfile.yml b/Taskfile.yml index db677d0..c6433d8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -31,6 +31,7 @@ tasks: - task: _set-app-key - cmd: "{{.DOCKER_EXEC_USER}} npm install" - cmd: "{{.DOCKER_EXEC_USER}} php artisan migrate" + - task: create-test-db frp:init: desc: "Add default FRP domains (api, app, mail)" @@ -110,9 +111,9 @@ tasks: - cmd: "{{.DOCKER_EXEC_USER}} php artisan {{.CLI_ARGS}}" test: - desc: "Run pest tests" + desc: "Run tests" cmds: - - cmd: "{{.DOCKER_EXEC_USER}} vendor/bin/pest {{.CLI_ARGS}}" + - cmd: "{{.DOCKER_EXEC_USER}} composer test" fix: desc: "Run fixers" @@ -135,3 +136,16 @@ tasks: echo "APP_KEY is not set. Creating:" docker compose exec {{.DOCKER_COMPOSE_APP_CONTAINER}} php artisan key:generate fi + + create-test-db: + desc: "Create Postgres test database inside db container (if not exists)" + vars: + DOCKER_COMPOSE_DATABASE_CONTAINER: database + DATABASE_USERNAME: '{{default .DB_USERNAME "MiniStravaAPI"}}' + TEST_DATABASE_NAME: '{{default .TEST_DB_DATABASE "MiniStravaAPI-test"}}' + cmds: + - | + docker compose exec {{.DOCKER_COMPOSE_DATABASE_CONTAINER}} \ + bash -lc 'createdb --username={{.DATABASE_USERNAME}} {{.TEST_DATABASE_NAME}} &> /dev/null \ + && echo "Created database for tests ({{.TEST_DATABASE_NAME}})." \ + || echo "Database for tests ({{.TEST_DATABASE_NAME}}) exists."' diff --git a/app/Actions/Avatars/ChangeAvatarAction.php b/app/Actions/Avatars/ChangeAvatarAction.php new file mode 100644 index 0000000..d9e171a --- /dev/null +++ b/app/Actions/Avatars/ChangeAvatarAction.php @@ -0,0 +1,17 @@ +storeAs("", $userId . ".png", "avatars"); + + return $stored !== false; + } +} diff --git a/app/Actions/Avatars/DeleteAvatarAction.php b/app/Actions/Avatars/DeleteAvatarAction.php new file mode 100644 index 0000000..8d478b9 --- /dev/null +++ b/app/Actions/Avatars/DeleteAvatarAction.php @@ -0,0 +1,23 @@ +exists($filename)) { + Storage::disk("avatars")->delete($filename); + + return true; + } + + return false; + } +} diff --git a/app/Actions/Avatars/GetAvatarAction.php b/app/Actions/Avatars/GetAvatarAction.php new file mode 100644 index 0000000..cf9fb13 --- /dev/null +++ b/app/Actions/Avatars/GetAvatarAction.php @@ -0,0 +1,21 @@ +exists($filename)) { + return Storage::disk("avatars")->get($filename); + } + + return null; + } +} diff --git a/app/Actions/Avatars/GetDefaultAvatarAction.php b/app/Actions/Avatars/GetDefaultAvatarAction.php new file mode 100644 index 0000000..1b8f4a6 --- /dev/null +++ b/app/Actions/Avatars/GetDefaultAvatarAction.php @@ -0,0 +1,18 @@ +getImageData((string)$userId, 300); + } +} diff --git a/app/Actions/Profile/UpdateProfileAction.php b/app/Actions/Profile/UpdateProfileAction.php new file mode 100644 index 0000000..c58a8cb --- /dev/null +++ b/app/Actions/Profile/UpdateProfileAction.php @@ -0,0 +1,18 @@ +fill($data); + $user->save(); + + return $user->fresh(); + } +} diff --git a/app/Enums/Gender.php b/app/Enums/Gender.php new file mode 100644 index 0000000..fd1dc4a --- /dev/null +++ b/app/Enums/Gender.php @@ -0,0 +1,11 @@ +identicon = new Identicon(); + } + + public static function url(string|int $name): string + { + return url("/api/profiles/{$name}/avatar"); + } + + public function create(string|int $filename, string $data): string + { + $image = $this->identicon->getImageData($data, 300); + + return $this->save($filename, $image); + } + + private function save(string|int $name, string $imageData): string + { + $path = $name . ".png"; + + Storage::disk("avatars")->put($path, $imageData); + + return Storage::disk("avatars")->url($path); + } +} diff --git a/app/Http/Controllers/Profile/ProfileController.php b/app/Http/Controllers/Profile/ProfileController.php new file mode 100644 index 0000000..92526b6 --- /dev/null +++ b/app/Http/Controllers/Profile/ProfileController.php @@ -0,0 +1,75 @@ +validated(); + + $user = $request->user(); + $updated = $updateProfileAction->execute($user, $validated); + + return UserResource::make($updated); + } + + public function show(Request $request): UserResource + { + $user = $request->user(); + + return UserResource::make($user); + } + + public function changeAvatar(ChangeAvatarRequest $request, ChangeAvatarAction $changeAvatarAction): UserResource + { + $user = $request->user(); + + $changeAvatarAction->execute( + $request->file("avatar"), + $user->id, + ); + + return UserResource::make($user); + } + + public function getAvatar(int $userId, GetAvatarAction $getAvatarAction, GetDefaultAvatarAction $getDefaultAvatarAction): Response + { + $avatar = $getAvatarAction->execute($userId); + + if ($avatar) { + return response($avatar) + ->header("Content-Type", "image/png") + ->header("Cache-Control", "max-age=31536000, public"); + } + + $defaultAvatar = $getDefaultAvatarAction->execute($userId); + + return response($defaultAvatar) + ->header("Content-Type", "image/svg+xml") + ->header("Cache-Control", "max-age=86400, public"); + } + + public function deleteAvatar(Request $request, DeleteAvatarAction $deleteAvatarAction): UserResource + { + $user = $request->user(); + + $deleteAvatarAction->execute($user->id); + + return UserResource::make($user); + } +} diff --git a/app/Http/Controllers/Profile/ProfilesController.php b/app/Http/Controllers/Profile/ProfilesController.php new file mode 100644 index 0000000..dedf69b --- /dev/null +++ b/app/Http/Controllers/Profile/ProfilesController.php @@ -0,0 +1,33 @@ +select()->paginate(10); + + return UserResource::collection($users); + } + + public function show(int $id): JsonResponse + { + $user = User::query()->find($id); + + if ($user === null) { + return response()->json([], Response::HTTP_NOT_FOUND); + } + + return response()->json(UserResource::make($user), Response::HTTP_OK); + } +} diff --git a/app/Http/Requests/ChangeAvatarRequest.php b/app/Http/Requests/ChangeAvatarRequest.php new file mode 100644 index 0000000..009f84d --- /dev/null +++ b/app/Http/Requests/ChangeAvatarRequest.php @@ -0,0 +1,31 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + "avatar" => ["required", "image", "mimes:png", "max:2048"], // max 2MB + ]; + } +} diff --git a/app/Http/Requests/UpdateProfileRequest.php b/app/Http/Requests/UpdateProfileRequest.php new file mode 100644 index 0000000..6d098a9 --- /dev/null +++ b/app/Http/Requests/UpdateProfileRequest.php @@ -0,0 +1,29 @@ +check(); + } + + public function rules(): array + { + return [ + "first_name" => ["nullable", "string", "max:255"], + "last_name" => ["nullable", "string", "max:255"], + "birth_date" => ["nullable", "date", "before:today"], + "height" => ["nullable", "integer", "min:50", "max:300"], + "weight" => ["nullable", "numeric", "min:10", "max:300"], + "gender" => ["nullable", new Enum(Gender::class)], + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..9dbba26 --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + "id" => $this->id, + "email" => $this->email, + "name" => $this->name, + "first_name" => $this->first_name, + "last_name" => $this->last_name, + "birth_date" => $this->birth_date?->format("Y-m-d"), + "height" => $this->height, + "weight" => $this->weight, + "avatar" => $this->avatar, + "created_at" => $this->created_at, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 03125e6..e99d192 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,16 +5,26 @@ namespace Strava\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; +use Strava\Enums\Gender; +use Strava\Helpers\IdenticonHelper; /** * @property int $id * @property string $name + * @property string|null $first_name + * @property string|null $last_name * @property string $email * @property string $password + * @property Carbon $birth_date + * @property int|null $height + * @property string|null $weight + * @property Gender $gender + * @property string $avatar * @property Carbon $email_verified_at * @property Carbon $created_at * @property Carbon $updated_at @@ -29,6 +39,12 @@ class User extends Authenticatable "name", "email", "password", + "first_name", + "last_name", + "birth_date", + "height", + "weight", + "gender", ]; protected $hidden = [ "password", @@ -39,7 +55,14 @@ protected function casts(): array { return [ "email_verified_at" => "datetime", + "birth_date" => "date", "password" => "hashed", + "gender" => Gender::class, ]; } + + protected function avatar(): Attribute + { + return Attribute::get(fn(): string => IdenticonHelper::url($this->id)); + } } diff --git a/composer.json b/composer.json index 4396298..7e1e087 100644 --- a/composer.json +++ b/composer.json @@ -9,9 +9,11 @@ "ext-pdo": "*", "guzzlehttp/guzzle": "^7.9.3", "inertiajs/inertia-laravel": "^2.0.3", + "intervention/image-laravel": "^1.5", "laravel/framework": "^12.20.0", "laravel/sanctum": "^4.1.2", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "yzalis/identicon": "^2.0" }, "require-dev": { "blumilksoftware/codestyle": "^v5.0.0", diff --git a/composer.lock b/composer.lock index fc642e8..0e9624a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "752c7ea01b88d876022bd9e620bea0b8", + "content-hash": "2f1a23fce8047cced39633cffc64fbce", "packages": [ { "name": "brick/math", @@ -1123,6 +1123,234 @@ }, "time": "2025-09-28T21:21:36+00:00" }, + { + "name": "intervention/gif", + "version": "4.2.2", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/5999eac6a39aa760fb803bc809e8909ee67b451a", + "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.2.2" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-03-29T07:46:21+00:00" + }, + { + "name": "intervention/image", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.2", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP image manipulation", + "homepage": "https://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.11.4" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-07-30T13:13:19+00:00" + }, + { + "name": "intervention/image-laravel", + "version": "1.5.6", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image-laravel.git", + "reference": "056029431400a5cc56036172787a578f334683c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image-laravel/zipball/056029431400a5cc56036172787a578f334683c4", + "reference": "056029431400a5cc56036172787a578f334683c4", + "shasum": "" + }, + "require": { + "illuminate/http": "^8|^9|^10|^11|^12", + "illuminate/routing": "^8|^9|^10|^11|^12", + "illuminate/support": "^8|^9|^10|^11|^12", + "intervention/image": "^3.11", + "php": "^8.1" + }, + "require-dev": { + "ext-fileinfo": "*", + "orchestra/testbench": "^8.18 || ^9.9", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Image": "Intervention\\Image\\Laravel\\Facades\\Image" + }, + "providers": [ + "Intervention\\Image\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Intervention\\Image\\Laravel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Laravel Integration of Intervention Image", + "homepage": "https://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "laravel", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image-laravel/issues", + "source": "https://github.com/Intervention/image-laravel/tree/1.5.6" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-04-04T15:09:55+00:00" + }, { "name": "laravel/framework", "version": "v12.33.0", @@ -6157,6 +6385,63 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "yzalis/identicon", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/yzalis/Identicon.git", + "reference": "ff5ed090129cab9bfa2a322857d4a01d107aa0ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yzalis/Identicon/zipball/ff5ed090129cab9bfa2a322857d4a01d107aa0ae", + "reference": "ff5ed090129cab9bfa2a322857d4a01d107aa0ae", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "ext-imagick": "*", + "fzaninotto/faker": "^1.2.0", + "phpunit/phpunit": "^4.0 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Identicon\\": "src/Identicon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Laugueux", + "email": "benjamin@yzalis.com" + } + ], + "description": "Create awesome unique avatar.", + "homepage": "http://identicon-php.org", + "keywords": [ + "avatar", + "identicon", + "image" + ], + "support": { + "issues": "https://github.com/yzalis/Identicon/issues", + "source": "https://github.com/yzalis/Identicon/tree/master" + }, + "abandoned": true, + "time": "2019-10-14T09:30:57+00:00" } ], "packages-dev": [ diff --git a/config/filesystems.php b/config/filesystems.php index b8b1b78..04ffe80 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -28,6 +28,12 @@ "use_path_style_endpoint" => env("AWS_USE_PATH_STYLE_ENDPOINT", false), "throw" => false, ], + "avatars" => [ + "driver" => "local", + "root" => storage_path("app/public/avatars"), + "url" => env("APP_URL") . "/storage/avatars", + "visibility" => "public", + ], ], "links" => [ public_path("storage") => storage_path("app/public"), diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index f6931ba..38f044f 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Strava\Models\User; @@ -19,7 +20,7 @@ public function definition(): array "name" => fake()->name(), "email" => fake()->unique()->safeEmail(), "email_verified_at" => now(), - "password" => "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", + "password" => Hash::make("password"), "remember_token" => Str::random(10), ]; } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 7c9f45d..d9e8ab3 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -12,9 +12,15 @@ public function up(): void Schema::create("users", function (Blueprint $table): void { $table->id(); $table->string("name"); + $table->string("first_name")->nullable(); + $table->string("last_name")->nullable(); $table->string("email")->unique(); $table->timestamp("email_verified_at")->nullable(); $table->string("password"); + $table->date("birth_date")->nullable(); + $table->unsignedSmallInteger("height")->nullable(); + $table->decimal("weight", 5, 2)->nullable(); + $table->string("gender")->nullable(); $table->rememberToken(); $table->timestamps(); }); diff --git a/phpunit.xml.ci b/phpunit.xml.ci new file mode 100644 index 0000000..f0c9aa5 --- /dev/null +++ b/phpunit.xml.ci @@ -0,0 +1,37 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./app + + + + + + + + + + + + + + + + + + + + diff --git a/routes/api.php b/routes/api.php index 55af1e1..a063c43 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,12 +9,19 @@ use Strava\Http\Controllers\Auth\LogoutController; use Strava\Http\Controllers\Auth\PasswordController; use Strava\Http\Controllers\Auth\RegisterController; +use Strava\Http\Controllers\Profile\ProfileController; +use Strava\Http\Controllers\Profile\ProfilesController; Route::middleware("auth:sanctum")->get("/user", fn(Request $request): JsonResponse => new JsonResponse($request->user())); Route::middleware(["auth:sanctum"])->group(function (): void { Route::post("/auth/logout", [LogoutController::class, "logout"])->name("logout"); + Route::get("/profile", [ProfileController::class, "show"])->name("profile.show"); + Route::patch("/profile", [ProfileController::class, "update"])->name("profile.update"); + Route::post("/profile/avatar", [ProfileController::class, "changeAvatar"])->name("profile.avatar.update"); + Route::delete("/profile/avatar", [ProfileController::class, "deleteAvatar"])->name("profile.avatar.delete"); + Route::post("/user/change-password", [PasswordController::class, "changePassword"])->name("change-password"); }); @@ -22,3 +29,7 @@ Route::post("/auth/register", [RegisterController::class, "register"])->name("register"); Route::post("/auth/forgot-password", [PasswordController::class, "sendResetEmail"])->name("forgot-password"); Route::post("/auth/reset-password", [PasswordController::class, "resetPassword"])->name("reset-password"); + +Route::get("/profiles", [ProfilesController::class, "index"])->name("profiles.index"); +Route::get("/profiles/{id}", [ProfilesController::class, "show"])->name("profiles.show"); +Route::get("/profiles/{id}/avatar", [ProfileController::class, "getAvatar"])->name("avatar.show"); diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..4198c1a --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,194 @@ +create([ + "name" => "Kacper", + "first_name" => "Kacper", + "last_name" => "Nazwisko", + "email" => "kacper@example.com", + "birth_date" => "2000-01-02", + "height" => 180, + "weight" => "78.50", + ]); + + $res = $this->acting($user)->getJson("/api/profile"); + + $this->assertUserResource($res, $user); + } + + public function testUpdatesProfileAndReturnsUserResource(): void + { + $user = User::factory()->create([ + "name" => "Old Username", + "first_name" => "Old", + "last_name" => "Name", + "email" => "old@example.com", + "birth_date" => "1999-02-03", + "height" => 170, + "weight" => "70.00", + ]); + + $payload = [ + "first_name" => "New", + "last_name" => "Name", + "birth_date" => "2001-10-11", + "height" => 190, + "weight" => "82.00", + ]; + + $res = $this->acting($user)->patchJson("/api/profile", $payload); + + $this->assertDatabaseHas("users", [ + "id" => $user->id, + "first_name" => "New", + "last_name" => "Name", + "birth_date" => "2001-10-11", + "height" => 190, + "weight" => "82.00", + ]); + + $this->assertUserResource($res, $user); + } + + public function testChangesAvatarAndStoresPngOnAvatarsDisk(): void + { + Storage::fake("avatars"); + + $user = User::factory()->create([ + "birth_date" => "2000-01-02", + "height" => 180, + "weight" => "78.50", + ]); + + $file = UploadedFile::fake()->create("avatar.png", 10, "image/png"); + + $res = $this->acting($user)->postJson("/api/profile/avatar", [ + "avatar" => $file, + ]); + + $this->assertUserResource($res, $user); + Storage::disk("avatars")->assertExists($user->id . ".png"); + } + + public function testReturnsPngAvatarIfExistsWithLongCacheHeaders(): void + { + Storage::fake("avatars"); + + $user = User::factory()->create(); + Storage::disk("avatars")->put($user->id . ".png", "PNGDATA"); + + $res = $this->get("/api/profiles/" . $user->id . "/avatar"); + + $res->assertOk(); + $res->assertHeader("Content-Type", "image/png"); + $res->assertHeader("Cache-Control", "max-age=31536000, public"); + $this->assertSame("PNGDATA", $res->getContent()); + } + + public function testReturnsSvgIdenticonIfAvatarMissingWithShorterCacheHeaders(): void + { + Storage::fake("avatars"); + + $user = User::factory()->create(); + + $res = $this->get("/api/profiles/" . $user->id . "/avatar"); + + $res->assertOk(); + $res->assertHeader("Content-Type", "image/svg+xml"); + $res->assertHeader("Cache-Control", "max-age=86400, public"); + $this->assertStringContainsString("getContent()); + } + + public function testDeletesAvatarIfExists(): void + { + Storage::fake("avatars"); + + $user = User::factory()->create([ + "birth_date" => "2000-01-02", + "height" => 180, + "weight" => "78.50", + ]); + + Storage::disk("avatars")->put($user->id . ".png", "PNGDATA"); + + $res = $this->acting($user)->deleteJson("/api/profile/avatar"); + + $this->assertUserResource($res, $user); + Storage::disk("avatars")->assertMissing($user->id . ".png"); + } + + public function testReturnsOkEvenIfAvatarDoesNotExistOnDelete(): void + { + Storage::fake("avatars"); + + $user = User::factory()->create([ + "birth_date" => "2000-01-02", + "height" => 180, + "weight" => "78.50", + ]); + + $res = $this->acting($user)->deleteJson("/api/profile/avatar"); + + $this->assertUserResource($res, $user); + Storage::disk("avatars")->assertMissing($user->id . ".png"); + } + + private function acting(User $user): self + { + return $this->actingAs($user); + } + + private function assertUserResource($res, User $user): void + { + $user->refresh(); + + $res->assertOk(); + $res->assertJsonStructure([ + "data" => [ + "id", + "email", + "name", + "first_name", + "last_name", + "birth_date", + "height", + "weight", + "avatar", + "created_at", + ], + ]); + + $data = $res->json("data"); + + $this->assertSame($user->id, $data["id"]); + $this->assertSame($user->email, $data["email"]); + $this->assertSame($user->name, $data["name"]); + $this->assertSame($user->first_name, $data["first_name"]); + $this->assertSame($user->last_name, $data["last_name"]); + $this->assertSame($user->height, $data["height"]); + $this->assertSame($user->weight, $data["weight"]); + + $this->assertSame(IdenticonHelper::url($user->id), $data["avatar"]); + + $expectedBirth = $user->birth_date?->toDateString(); + $this->assertSame($expectedBirth, (string)$data["birth_date"]); + + $this->assertSame($user->created_at->toJSON(), $data["created_at"]); + } +}