create profile api's with tests

This commit is contained in:
2024-08-20 08:04:33 +00:00
committed by Hamidreza Ranjbarpour
parent f1626a9d67
commit c710183aa9
18 changed files with 979 additions and 83 deletions

107
app/Facades/File/File.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
namespace App\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\File as HttpFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
class File
{
public function save(HttpFile|UploadedFile $file, string $path, string $name = null, string $disk = 'public'): string
{
return Storage::disk($disk)->putFileAs(
$path,
$file,
$name ?? Str::uuid() . '.' . $file->extension()
);
}
/**
* @param string $filePath
* @param bool $url_is_absolute
* @param string $disk
* @return void
* @throws \InvalidArgumentException when filePath is empty.
*/
public function delete(string $filePath, bool $url_is_absolute = false, string $disk = 'public'): void
{
if (!$filePath){
throw new \InvalidArgumentException('File path is invalid.');
}
if ($url_is_absolute) {
$needle = 'storage/';
$pos = strpos($filePath, $needle);
$filePath = substr($filePath, $pos + strlen($needle));
}
if (Storage::disk($disk)->exists($filePath)) {
Storage::disk($disk)->delete($filePath);
}
}
public function deleteContent($file_path): bool
{
if (file_exists($file_path)) {
file_put_contents($file_path, '');
return true;
}
return false;
}
public function tail($file_path, $lines = 100, $buffer = 2048): bool|string
{
// Open file
$file = @fopen($file_path, "rb");
if ($file === false) {
return false;
}
// Jump to last character
fseek($file, -1, SEEK_END);
// Read it and adjust line number if necessary
// (Otherwise the result would be wrong if file doesn't end with a blank line)
if (fread($file, 1) != "\n") {
$lines -= 1;
}
// Start reading
$output = '';
$chunk = '';
// While we would like more
while (ftell($file) > 0 && $lines >= 0) {
// Figure out how far back we should jump
$seek = min(ftell($file), $buffer);
// Do the jump (backwards, relative to where we are)
fseek($file, -$seek, SEEK_CUR);
// Read a chunk and prepend it to our output
$output = ($chunk = fread($file, $seek)) . $output;
// Jump back to where we started reading
fseek($file, -mb_strlen($chunk, '8bit'), SEEK_CUR);
// Decrease our line counter
$lines -= substr_count($chunk, "\n");
}
// While we have too many lines
// (Because of buffer size we might have read too many)
while ($lines++ < 0) {
// Find first newline and remove all text before that
$output = substr($output, strpos($output, "\n") + 1);
}
// Close file and return
fclose($file);
return trim($output);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Facades\File;
use Illuminate\Support\Facades\Facade;
class FileFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'file';
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\V3;
use App\Facades\File\FileFacade;
use App\Http\Controllers\Controller;
use App\Http\Requests\V3\Profile\ChangePasswordRequest;
use App\Http\Requests\V3\Profile\EditRequest;
use App\Http\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
class ProfileController extends Controller
{
use ApiResponse;
public function info(): JsonResponse
{
$user = Auth::user();
return $this->successResponse([
'username' => $user->username,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'name' => $user->name,
'last_login' => $user->last_login,
'major' => $user->major,
'degree' => $user->degree,
'mobile' => $user->mobile,
'avatar' => $user->avatar,
]);
}
public function edit(EditRequest $request): JsonResponse
{
$user = Auth::user();
if ($request->has('avatar'))
{
if ($user->avatar) {
FileFacade::delete($user->avatar);
}
$destinationPath = 'avatar/' . $user->username;
$filename = time() .".". $request->file('avatar')->getClientOriginalExtension();
$avatar = FileFacade::save($request->avatar, $destinationPath, $filename);
}
$user->update([
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'major' => $request->major,
'degree' => $request->degree,
'mobile' => $request->mobile,
'avatar' => $avatar ?? $user->avatar
]);
return $this->successResponse();
}
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
$user = Auth::user();
if (! Hash::check($request->current_password, $user->password))
{
return $this->errorResponse(__('messages.incorrect_current_password'));
}
$user->update([
'password' => Hash::make($request->new_password)
]);
return $this->successResponse();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\V3\Profile;
use Illuminate\Foundation\Http\FormRequest;
class ChangePasswordRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'current_password' => 'required|string|regex:/^(?=.*[a-zA-Z])(?=.*\d).+$/|min:8',
'new_password' => 'required|string|regex:/^(?=.*[a-zA-Z])(?=.*\d).+$/|min:8',
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\V3\Profile;
use Illuminate\Foundation\Http\FormRequest;
class EditRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'first_name' => 'required|string',
'last_name' => 'required|string',
'degree' => 'required',
'major' => 'required',
'mobile' => 'required|regex:/^09\d{9}$/|numeric|digits:11',
'avatar' => 'mimes:png,jpg,jpeg|max:2048'
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use App\Facades\File\File;
use Illuminate\Support\ServiceProvider;
class FileServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register(): void
{
$this->app->bind('file', function () {
return new File();
});
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
}
}

View File

@@ -69,8 +69,8 @@ class RouteServiceProvider extends ServiceProvider
->group(base_path('routes/v2.php'));
Route::prefix('api/v3')
->name('v3')
->middleware('web', 'auth', 'confirmUser')
->name('v3.')
->middleware(['web', 'auth', 'confirmUser'])
->namespace($this->namespace)
->group(base_path('routes/v3.php'));
Route::prefix('api')

View File

@@ -45,6 +45,7 @@
41 => 'App\\Providers\\TelescopeServiceProvider',
42 => 'Barryvdh\\Debugbar\\ServiceProvider',
43 => 'App\\Providers\\DataTableServiceProvider',
44 => 'App\\Providers\\FileServiceProvider',
),
'eager' =>
array (
@@ -78,6 +79,7 @@
27 => 'App\\Providers\\TelescopeServiceProvider',
28 => 'Barryvdh\\Debugbar\\ServiceProvider',
29 => 'App\\Providers\\DataTableServiceProvider',
30 => 'App\\Providers\\FileServiceProvider',
),
'deferred' =>
array (

View File

@@ -183,6 +183,7 @@ return [
// custom facades here..
App\Providers\DataTableServiceProvider::class,
App\Providers\FileServiceProvider::class,
],

View File

@@ -3,6 +3,7 @@
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
@@ -23,6 +24,8 @@ class UserFactory extends Factory
'confirmed' => 1,
'province_fa' => $this->faker->name,
'city_fa' => $this->faker->name,
'password' => Hash::make('password1234'),
'avatar' => $this->faker->image
];
}
}

View File

@@ -0,0 +1,6 @@
<?php
return [
'successful' => 'The Operation was successful',
'incorrect_current_password' => 'The current password is incorrect.',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'successful' => 'عملیات موفق آمیز بود',
'incorrect_current_password' => 'رمز عبور فعلی اشتباه است',
];

View File

@@ -174,6 +174,11 @@ return [
"available" => "موجود",
"size" => "اندازه",
"file" => "فایل",
"fullname" => "نام کامل"
"fullname" => "نام کامل",
'current_password' => 'رمز عبور فعلی',
'new_password' => 'رمز عبور جدید',
'degree' => 'مدرک تحصیلی',
'major' => 'شغل',
'avatar' => 'آواتار'
],
];

View File

@@ -1,6 +1,8 @@
<?php
use App\Http\Controllers\V3\Harim\DivarkeshiController;
use App\Http\Controllers\V3\ProfileController;
use Illuminate\Support\Facades\Route;
Route::prefix('harim')->name('harim')->group(function () {
Route::prefix('divarkeshi')->name('divarkeshi')->controller(DivarkeshiController::class)->group(function () {
@@ -21,3 +23,12 @@ Route::prefix('harim')->name('harim')->group(function () {
// Route::get('/{contract}', 'V3\Dashboard\ContractController@detail')->name('V3.contract.detail');
});
});
Route::prefix('profile')
->name('profile.')
->controller(ProfileController::class)
->group(function () {
Route::get('info', 'info')->name('info');
Route::post('edit', 'edit')->name('edit');
Route::post('change_password', 'changePassword')->name('changePassword');
});

View File

@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature\Facades;
use App\Facades\File\FileFacade;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FileTest extends TestCase
{
use WithFaker;
/**
* A basic feature test example.
*/
public function test_image_is_saved(): void
{
Storage::fake('public');
$inputFile = UploadedFile::fake()->image($this->faker->image);
$path = FileFacade::save($inputFile, 'testDir');
Storage::disk('public')->assertExists($path);
}
public function test_image_is_saved_with_specified_name(): void
{
Storage::fake('public');
$imageName = 'photo.png';
$inputFile = UploadedFile::fake()->image($imageName);
$actualPath = FileFacade::save($inputFile, 'testDir', $imageName);
$expectedPath = 'testDir/photo.png';
Storage::disk('public')->assertExists($actualPath);
$this->assertEquals($expectedPath, $actualPath);
}
public function test_file_is_saved(): void
{
Storage::fake('public');
$fileName = 'fake' . '.' . $this->faker->fileExtension();
$inputFile = UploadedFile::fake()->create($fileName);
$path = FileFacade::save($inputFile, 'testDir');
Storage::disk('public')->assertExists($path);
}
public function test_file_is_saved_with_specified_name(): void
{
Storage::fake('public');
$fileName = 'fake' . '.' . $this->faker->fileExtension();
$inputFile = UploadedFile::fake()->create($fileName);
$actualPath = FileFacade::save($inputFile, 'testDir', $fileName);
$expectedPath = 'testDir/' . $fileName;
Storage::disk('public')->assertExists($actualPath);
$this->assertEquals($expectedPath, $actualPath);
}
public function test_file_is_saved_with_specified_disk(): void
{
Storage::fake('public');
Storage::fake('p2');
$fileName = 'fake' . '.' . $this->faker->fileExtension();
$inputFile = UploadedFile::fake()->create($fileName);
$path = FileFacade::save($inputFile, 'testDir', disk: 'p2');
Storage::disk('p2')->assertExists($path);
Storage::disk('public')->assertMissing($path);
}
public function test_delete_throws_exception_with_empty_file_path(): void
{
$this->assertThrows(
fn() => FileFacade::delete(''),
\InvalidArgumentException::class
);
}
public function test_file_is_deleted(): void
{
Storage::fake('public');
$fileName = 'fake' . '.' . $this->faker->fileExtension();
$inputFile = UploadedFile::fake()->create($fileName);
$path = Storage::disk('public')->putFile('testDir', $inputFile);
Storage::disk('public')->assertExists($path);
FileFacade::delete($path);
Storage::disk('public')->assertMissing($path);
}
public function test_file_with_absolute_path_is_deleted(): void
{
Storage::fake('public');
$fileName = 'fake' . '.' . $this->faker->fileExtension();
$inputFile = UploadedFile::fake()->create($fileName);
$path = Storage::disk('public')->putFile('testDir', $inputFile);
$absolutePath = Storage::disk('public')->url($path);
Storage::disk('public')->assertExists($path);
FileFacade::delete($absolutePath, url_is_absolute: true);
Storage::disk('public')->assertMissing($path);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace Tests\Feature\V3\Profile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class ChangePasswordTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function test_current_password_is_required()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'));
$response->assertUnprocessable()
->assertInvalid([
'current_password' => __('validation.required', ['attribute' => 'رمز عبور فعلی'])
]);
}
public function test_current_password_should_be_string()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'), [
'current_password' => $this->faker->numberBetween(1, 10)
]);
$response->assertUnprocessable()
->assertInvalid([
'current_password' => __('validation.string', ['attribute' => 'رمز عبور فعلی'])
]);
}
public function test_current_password_should_match_the_regex()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'), [
'current_password' => $this->faker->numberBetween(10000000, 99999999)
]);
$response->assertUnprocessable()
->assertInvalid([
'current_password' => __('validation.regex', ['attribute' => 'رمز عبور فعلی'])
]);
}
public function test_current_password_should_have_at_least_8_characters()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'), [
'current_password' => $this->faker->numerify()
]);
$response->assertUnprocessable()
->assertInvalid([
'current_password' => __('validation.min.string', ['attribute' => 'رمز عبور فعلی', 'min' => 8])
]);
}
public function test_new_password_is_required()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'));
$response->assertUnprocessable()
->assertInvalid([
'new_password' => __('validation.required', ['attribute' => 'رمز عبور جدید'])
]);
}
public function test_new_password_should_be_string()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'), [
'new_password' => $this->faker->numberBetween(1, 10)
]);
$response->assertUnprocessable()
->assertInvalid([
'new_password' => __('validation.string', ['attribute' => 'رمز عبور جدید'])
]);
}
public function test_new_password_should_match_the_regex()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'), [
'new_password' => $this->faker->numberBetween(10000000, 99999999)
]);
$response->assertUnprocessable()
->assertInvalid([
'new_password' => __('validation.regex', ['attribute' => 'رمز عبور جدید'])
]);
}
public function test_new_password_should_have_at_least_8_characters()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.changePassword'), [
'new_password' => $this->faker->numerify()
]);
$response->assertUnprocessable()
->assertInvalid([
'new_password' => __('validation.min.string', ['attribute' => 'رمز عبور جدید', 'min' => 8])
]);
}
public function test_can_not_change_the_password_of_the_user_with_wrong_current_password(): void
{
$this->withoutExceptionHandling();
$user = User::factory()->create();
$this->actingAs($user);
$password = $this->faker->numerify('#a######');
$response = $this->post(route('v3.profile.changePassword'), [
'current_password' => 'fake1234',
'new_password' => $password,
]);
$response->assertStatus(422);
$response->assertExactJson([
'message' => __('messages.incorrect_current_password'),
"type" => "logical_exception"
]);
}
public function test_user_can_change_the_password(): void
{
$this->withoutExceptionHandling();
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('v3.profile.changePassword'), [
'current_password' => 'password1234',
'new_password' => $this->faker->numerify('#a######'),
]);
$response->assertStatus(200);
$response->assertExactJson([
'message' => __('messages.successful')
]);
}
}

View File

@@ -0,0 +1,239 @@
<?php
namespace Tests\Feature\V3\Profile;
use App\Facades\File\FileFacade;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class EditTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function test_first_name_is_required()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'));
$response->assertUnprocessable()
->assertInvalid([
'first_name' => __('validation.required', ['attribute' => 'نام'])
]);
}
public function test_first_name_should_be_string()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'), [
'first_name' => $this->faker->numberBetween(0, 10)
]);
$response->assertUnprocessable()
->assertInvalid([
'first_name' => __('validation.string', ['attribute' => 'نام'])
]);
}
public function test_last_name_is_required()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'));
$response->assertUnprocessable()
->assertInvalid([
'last_name' => __('validation.required', ['attribute' => 'نام خانوادگی'])
]);
}
public function test_last_name_should_be_string()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'), [
'last_name' => $this->faker->numberBetween(0, 10)
]);
$response->assertUnprocessable()
->assertInvalid([
'last_name' => __('validation.string', ['attribute' => 'نام خانوادگی'])
]);
}
public function test_degree_is_required()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'));
$response->assertUnprocessable()
->assertInvalid([
'degree' => __('validation.required', ['attribute' => 'مدرک تحصیلی'])
]);
}
public function test_major_is_required()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'));
$response->assertUnprocessable()
->assertInvalid([
'major' => __('validation.required', ['attribute' => 'شغل'])
]);
}
public function test_mobile_is_required()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'));
$response->assertUnprocessable()
->assertInvalid([
'mobile' => __('validation.required', ['attribute' => 'تلفن همراه'])
]);
}
public function test_mobile_should_match_the_regex()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'), [
'mobile' => $this->faker->numberBetween(1000000, 9999999)
]);
$response->assertUnprocessable()
->assertInvalid([
'mobile' => __('validation.regex', ['attribute' => 'تلفن همراه'])
]);
}
public function test_mobile_should_be_numeric()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'), [
'mobile' => $this->faker->name()
]);
$response->assertUnprocessable()
->assertInvalid([
'mobile' => __('validation.numeric', ['attribute' => 'تلفن همراه'])
]);
}
public function test_mobile_should_be_11_digits()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson(route('v3.profile.edit'), [
'mobile' => $this->faker->numberBetween(10000, 99999)
]);
$response->assertUnprocessable()
->assertInvalid([
'mobile' => __('validation.digits', ['attribute' => 'تلفن همراه', 'digits' => 11])
]);
}
public function test_avatar_should_match_the_mimes()
{
$user = User::factory()->create();
$this->actingAs($user);
$image = UploadedFile::fake()->create('image.txt', 40);
$response = $this->postJson(route('v3.profile.edit'), [
'avatar' => $image
]);
$response->assertUnprocessable()
->assertInvalid([
'avatar' => __('validation.mimes', ['attribute' => 'آواتار', 'values' => 'png, jpg, jpeg'])
]);
}
public function test_avatar_should_be_less_than_2048kb()
{
$user = User::factory()->create();
$this->actingAs($user);
$image = UploadedFile::fake()->create('image.jpg', 40000);
$response = $this->postJson(route('v3.profile.edit'), [
'avatar' => $image
]);
$response->assertUnprocessable()
->assertInvalid([
'avatar' => __('validation.max.file', ['attribute' => 'آواتار', 'max' => 2048])
]);
}
public function test_user_can_edit_the_profile()
{
$user = User::factory()->create();
$this->actingAs($user);
$avatar = UploadedFile::fake()->create('image.jpg', 1024);
FileFacade::shouldReceive('save')
->andReturn('image.jpg');
FileFacade::shouldReceive('delete');
$data = [
'first_name' => $this->faker->firstName,
'last_name' => $this->faker->lastName,
'degree' => $this->faker->name,
'major' => $this->faker->name,
'mobile' => $this->faker->numerify('09#########'),
'avatar' => $avatar
];
$response = $this->postJson(route('v3.profile.edit'), $data);
$response->assertOk()
->assertExactJson([
'message' => __('messages.successful')
]);
$this->assertDatabaseHas('users', [
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'mobile' => $data['mobile'],
'degree' => $data['degree'],
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\V3\Profile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class InfoTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function test_user_info_returns_successfully(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('v3.profile.info'));
$response->assertStatus(200);
$response->assertExactJson([
'data' => [
'username' => $user->username,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'name' => $user->name,
'last_login' => $user->last_login,
'major' => $user->major,
'degree' => $user->degree,
'mobile' => $user->mobile,
'avatar' => $user->avatar,
]
]);
}
}