diff --git a/.github/skills/socialite-development/SKILL.md b/.github/skills/socialite-development/SKILL.md deleted file mode 100644 index 562e417..0000000 --- a/.github/skills/socialite-development/SKILL.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: socialite-development -description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication." -license: MIT -metadata: - author: laravel ---- - -# Socialite Authentication - -## Documentation - -Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth). - -## Available Providers - -Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch` - -Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`. - -Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`. - -Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand. - -Community providers differ from built-in providers in the following ways: -- Installed via `composer require socialiteproviders/{name}` -- Must register via event listener — NOT auto-discovered like built-in providers -- Use `search-docs` for the registration pattern - -## Adding a Provider - -### 1. Configure the provider - -Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly. - -### 2. Create redirect and callback routes - -Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details. - -### 3. Authenticate and store the user - -In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`. - -### 4. Customize the redirect (optional) - -- `scopes()` — merge additional scopes with the provider's defaults -- `setScopes()` — replace all scopes entirely -- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google) -- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object. -- `stateless()` — for API/SPA contexts where session state is not maintained - -### 5. Verify - -1. Config key matches driver name exactly (check the list above for hyphenated names) -2. `client_id`, `client_secret`, and `redirect` are all present -3. Redirect URL matches what is registered in the provider's OAuth dashboard -4. Callback route handles denied grants (when user declines authorization) - -Use `search-docs` for complete code examples of each step. - -## Additional Features - -Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details. - -User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes` - -## Testing - -Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods. - -## Common Pitfalls - -- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails. -- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors. -- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely. -- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`. -- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol). -- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved. -- Community providers require event listener registration via `SocialiteWasCalled`. -- `user()` throws when the user declines authorization. Always handle denied grants. diff --git a/.gitignore b/.gitignore index 0221c45..2bf3f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ /public/storage /storage/*.key /storage/pail +/storage/api-docs/api-docs.json storage/phpstan /vendor _ide_helper.php diff --git a/app/Http/Controllers/Api/V1/CategoryController.php b/app/Http/Controllers/Api/V1/CategoryController.php new file mode 100644 index 0000000..0c38082 --- /dev/null +++ b/app/Http/Controllers/Api/V1/CategoryController.php @@ -0,0 +1,224 @@ +orderBy('sort_order') + ->orderBy('name') + ->paginate(20); + + return response()->json($categories, 200); + } + + #[OA\Post( + path: '/api/v1/categories', + operationId: 'categoryStore', + summary: 'Create a category', + description: 'Creates a new developer category. The slug is auto-generated from the name when omitted.', + tags: ['Category'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['name'], + properties: [ + new OA\Property(property: 'name', type: 'string', example: 'Backend'), + new OA\Property(property: 'slug', type: 'string', example: 'backend'), + new OA\Property(property: 'description', type: 'string', nullable: true, example: 'Server-side development'), + new OA\Property(property: 'is_active', type: 'boolean', example: true), + new OA\Property(property: 'sort_order', type: 'integer', example: 0), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Category created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'data', ref: '#/components/schemas/Category'), + ], + type: 'object' + ) + ), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255', 'unique:categories,slug'], + 'description' => ['nullable', 'string'], + 'is_active' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer', 'min:0'], + ]); + + $category = Category::create($validated); + + return response()->json(['data' => $category], 201); + } + + #[OA\Get( + path: '/api/v1/categories/{category}', + operationId: 'categoryShow', + summary: 'Get a single category', + description: 'Returns a category by ID.', + tags: ['Category'], + parameters: [ + new OA\Parameter( + name: 'category', + description: 'ID of the category to retrieve', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer', example: 1) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Category retrieved successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'data', ref: '#/components/schemas/Category'), + ], + type: 'object' + ) + ), + new OA\Response(response: 404, description: 'Category not found'), + ] + )] + public function show(Category $category): JsonResponse + { + return response()->json(['data' => $category], 200); + } + + #[OA\Put( + path: '/api/v1/categories/{category}', + operationId: 'categoryUpdate', + summary: 'Update a category', + description: 'Updates an existing category.', + tags: ['Category'], + parameters: [ + new OA\Parameter( + name: 'category', + description: 'ID of the category to update', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer', example: 1) + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', example: 'Backend'), + new OA\Property(property: 'slug', type: 'string', example: 'backend'), + new OA\Property(property: 'description', type: 'string', nullable: true, example: 'Server-side development'), + new OA\Property(property: 'is_active', type: 'boolean', example: true), + new OA\Property(property: 'sort_order', type: 'integer', example: 0), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Category updated successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'data', ref: '#/components/schemas/Category'), + ], + type: 'object' + ) + ), + new OA\Response(response: 404, description: 'Category not found'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function update(Request $request, Category $category): JsonResponse + { + $validated = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'slug' => [ + 'nullable', + 'string', + 'max:255', + Rule::unique('categories', 'slug')->ignore($category->id), + ], + 'description' => ['nullable', 'string'], + 'is_active' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer', 'min:0'], + ]); + + $category->update($validated); + + return response()->json(['data' => $category], 200); + } + + #[OA\Delete( + path: '/api/v1/categories/{category}', + operationId: 'categoryDestroy', + summary: 'Delete a category', + description: 'Soft-deletes a category.', + tags: ['Category'], + parameters: [ + new OA\Parameter( + name: 'category', + description: 'ID of the category to delete', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer', example: 1) + ), + ], + responses: [ + new OA\Response(response: 204, description: 'Category deleted successfully'), + new OA\Response(response: 404, description: 'Category not found'), + ] + )] + public function destroy(Category $category): JsonResponse + { + $category->delete(); + + return response()->json(['data' => null, 'message' => 'Category deleted successfully'], 204); + } +} diff --git a/app/Http/Controllers/Api/V1/ProfileController.php b/app/Http/Controllers/Api/V1/ProfileController.php index e34b75b..526177b 100644 --- a/app/Http/Controllers/Api/V1/ProfileController.php +++ b/app/Http/Controllers/Api/V1/ProfileController.php @@ -21,7 +21,7 @@ public function __construct(private ProfileService $profileService) path: '/api/v1/profiles', operationId: 'profileIndex', summary: 'List profiles', - description: 'Returns a paginated list of profiles, including their skills and projects.', + description: 'Returns a paginated list of profiles, including their skills, projects and category.', tags: ['Profile'], parameters: [ new OA\Parameter( @@ -55,7 +55,7 @@ public function __construct(private ProfileService $profileService) )] public function index(): JsonResponse { - $posts = Profile::with(['skills', 'projects'])->paginate(20); + $posts = Profile::with(['skills', 'projects', 'category'])->paginate(20); return response()->json($posts, 200); } @@ -93,7 +93,7 @@ public function store(Request $request): JsonResponse path: '/api/v1/profiles/{profile}', operationId: 'profileShow', summary: 'Get a single profile', - description: 'Returns a profile (route-model-bound by ID) along with its skills and projects.', + description: 'Returns a profile (route-model-bound by ID) along with its skills, projects and category.', tags: ['Profile'], parameters: [ new OA\Parameter( @@ -129,7 +129,7 @@ public function store(Request $request): JsonResponse )] public function show(Profile $profile): JsonResponse { - return response()->json(['data' => $profile->load(['skills', 'projects'])], 200); + return response()->json(['data' => $profile->load(['skills', 'projects', 'category'])], 200); } diff --git a/app/Http/Requests/UpdateProfileRequest.php b/app/Http/Requests/UpdateProfileRequest.php index 36e9054..dc70949 100644 --- a/app/Http/Requests/UpdateProfileRequest.php +++ b/app/Http/Requests/UpdateProfileRequest.php @@ -16,6 +16,13 @@ new OA\Property(property: 'bio', type: 'string', description: 'The profile bio', example: 'Product designer based in Lille.'), new OA\Property(property: 'headline', type: 'string', description: 'The profile headline', example: 'Senior Product Designer'), new OA\Property(property: 'location', type: 'string', description: 'The profile location', example: 'Lille, France'), + new OA\Property( + property: 'category_id', + type: 'integer', + nullable: true, + description: 'ID of the category to assign to this profile', + example: 1 + ), new OA\Property( property: 'skills', description: 'Skills linked to this profile', @@ -67,6 +74,7 @@ public function rules(): array 'bio' => ['nullable', 'string', 'max:2000'], 'headline' => ['nullable', 'string', 'max:2000'], 'location' => ['nullable', 'string', 'max:255'], + 'category_id' => ['nullable', 'integer', 'exists:categories,id'], // Skills 'skills' => ['sometimes', 'array'], diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..3cc30f8 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,78 @@ + */ + use HasFactory; + use SoftDeletes; + + protected $fillable = [ + 'name', + 'slug', + 'description', + 'is_active', + 'sort_order', + ]; + + protected $attributes = [ + 'is_active' => true, + 'sort_order' => 0, + ]; + + protected $casts = [ + 'is_active' => 'boolean', + ]; + + protected static function booted(): void + { + static::creating(function (Category $category) { + if ($category->slug === null || $category->slug === '') { + $category->slug = Str::slug($category->name); + } + }); + } + + /** + * Les profils appartenant à cette catégorie. + * + * @return HasMany + */ + public function profiles(): HasMany + { + return $this->hasMany(Profile::class); + } + + /** + * Les projets appartenant à cette catégorie. + * + * @return HasMany + */ + public function projects(): HasMany + { + return $this->hasMany(Project::class); + } +} diff --git a/app/Models/Profile.php b/app/Models/Profile.php index 08dd257..be41945 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -39,11 +39,24 @@ type: 'array', items: new OA\Items(ref: '#/components/schemas/Project') ), + new OA\Property( + property: 'category_id', + type: 'integer', + nullable: true, + description: 'The associated category ID', + example: 1 + ), + new OA\Property( + property: 'category', + nullable: true, + description: 'The category linked to this profile', + ref: '#/components/schemas/Category' + ), new OA\Property(property: 'created_at', type: 'string', format: 'date-time', description: 'The creation timestamp'), new OA\Property(property: 'updated_at', type: 'string', format: 'date-time', description: 'The update timestamp'), ] )] -#[Fillable(['user_id', 'email', 'name', 'linkedin_id', 'headline', 'bio', 'avatar_url', 'location', 'status', 'account_status'])] +#[Fillable(['user_id', 'email', 'name', 'linkedin_id', 'headline', 'bio', 'avatar_url', 'location', 'status', 'account_status', 'category_id'])] class Profile extends BaseModel { /** @use HasFactory<\Database\Factories\ProfileFactory> */ @@ -62,6 +75,14 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + /** + * @return BelongsTo + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + /** * @return HasMany */ diff --git a/app/Services/ProfileService.php b/app/Services/ProfileService.php index 16386d7..03a8e34 100644 --- a/app/Services/ProfileService.php +++ b/app/Services/ProfileService.php @@ -16,7 +16,7 @@ public function updateProfile(Profile $profile, array $validatedData): Profile { DB::transaction(function () use ($validatedData, $profile) { // 1. Update basic profile fields - $profile->update(collect($validatedData)->only(['name', 'bio', 'location', 'headline'])->toArray()); + $profile->update(collect($validatedData)->only(['name', 'bio', 'location', 'headline', 'category_id'])->toArray()); // 2. Sync skills (only touches this if 'skills' key was sent) if (array_key_exists('skills', $validatedData)) { @@ -29,7 +29,7 @@ public function updateProfile(Profile $profile, array $validatedData): Profile } }); - return $profile->load(['skills', 'projects']); + return $profile->load(['skills', 'projects', 'category']); } /** diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..fff2756 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,29 @@ + + */ +class CategoryFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->words(2, true); + + return [ + 'name' => ucwords($name), + 'slug' => Str::slug($name), + 'description' => fake()->optional()->sentence(), + 'is_active' => true, + 'sort_order' => fake()->numberBetween(0, 100), + ]; + } +} diff --git a/database/migrations/2026_08_01_123632_create_categories_table.php b/database/migrations/2026_08_01_123632_create_categories_table.php new file mode 100644 index 0000000..fe0c2ce --- /dev/null +++ b/database/migrations/2026_08_01_123632_create_categories_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + + $table->text('description')->nullable(); + + $table->boolean('is_active')->default(true); + + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_08_22_130000_add_category_id_to_profiles_table.php b/database/migrations/2026_08_22_130000_add_category_id_to_profiles_table.php new file mode 100644 index 0000000..d0f5534 --- /dev/null +++ b/database/migrations/2026_08_22_130000_add_category_id_to_profiles_table.php @@ -0,0 +1,32 @@ +foreignId('category_id') + ->nullable() + ->after('account_status') + ->constrained('categories') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('profiles', function (Blueprint $table) { + $table->dropConstrainedForeignId('category_id'); + }); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..b2e0b17 --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,42 @@ + $name) { + Category::query()->firstOrCreate( + ['slug' => Str::slug($name)], + [ + 'name' => $name, + 'is_active' => true, + 'sort_order' => $sortOrder + 1, + ] + ); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 5479527..9aebd2a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + CategorySeeder::class, SkillSeeder::class, ProfileSeeder::class, ]); diff --git a/routes/api_v1.php b/routes/api_v1.php index fed1810..b3982b7 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -1,6 +1,6 @@ name('auth.index'); @@ -19,5 +19,9 @@ Route::get('users/{user}', [UserController::class, 'show'])->name('users.show'); + +// }); Route::apiResource('skills', SkillController::class); + + Route::apiResource('categories', CategoryController::class); }); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json deleted file mode 100644 index b028a6d..0000000 --- a/storage/api-docs/api-docs.json +++ /dev/null @@ -1,1278 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "My API Documentation", - "description": "REST API for managing Skills", - "version": "1.0.0" - }, - "servers": [ - { - "url": "/api", - "description": "API Server" - } - ], - "paths": { - "/api/v1/auth": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Redirect the user to LinkedIn for authentication", - "description": "Redirects the user to LinkedIn for OAuth authentication.", - "operationId": "authRedirect", - "responses": { - "302": { - "description": "Redirect to LinkedIn OAuth" - } - } - } - }, - "/api/v1/auth/sign-up": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Sign up or log in a user via LinkedIn OAuth", - "description": "Authenticates the user against LinkedIn, then either creates a new account or logs the user in if one already exists. Returns 201 if a new account was created, 200 if an existing user logged in.", - "operationId": "authSignUp", - "responses": { - "200": { - "description": "Existing user successfully logged in", - "content": { - "application/json": { - "schema": { - "properties": { - "user": { - "$ref": "#/components/schemas/User" - }, - "token": { - "type": "string", - "example": "1|abcdef123456..." - } - }, - "type": "object" - } - } - } - }, - "201": { - "description": "New user successfully created", - "content": { - "application/json": { - "schema": { - "properties": { - "user": { - "$ref": "#/components/schemas/User" - }, - "token": { - "type": "string", - "example": "1|abcdef123456..." - } - }, - "type": "object" - } - } - } - }, - "421": { - "description": "Could not authenticate with LinkedIn", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Could not authenticate with LinkedIn." - } - }, - "type": "object" - } - } - } - }, - "422": { - "description": "LinkedIn did not return an email address", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "LinkedIn did not return an email address." - } - }, - "type": "object" - } - } - } - }, - "500": { - "description": "Unexpected error while signing in or creating the user", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Something went wrong when signing in or refreshing the user." - } - }, - "type": "object" - } - } - } - } - } - } - }, - "/api/v1/auth/exchange-code": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Exchange a one-time code for a user and token", - "description": "Exchanges a one-time code obtained from LinkedIn OAuth for a user and token.", - "operationId": "authExchangeCode", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string", - "example": "one-time-code" - } - }, - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "One-time code exchanged successfully", - "content": { - "application/json": { - "schema": { - "properties": { - "token": { - "type": "string", - "example": "1|abcdef123456..." - }, - "user": { - "$ref": "#/components/schemas/User" - } - }, - "type": "object" - } - } - } - }, - "401": { - "description": "Invalid or expired one-time code", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Invalid or expired code." - } - }, - "type": "object" - } - } - } - }, - "404": { - "description": "User not found for the provided one-time code", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "App\\Models\\User not found." - }, - "code": { - "type": "string", - "example": "MODEL_NOT_FOUND" - } - }, - "type": "object" - } - } - } - }, - "500": { - "description": "Internal server error while exchanging the one-time code", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Internal server error." - }, - "code": { - "type": "string", - "example": "INTERNAL_SERVER_ERROR" - } - }, - "type": "object" - } - } - } - } - } - } - }, - "/api/v1/me": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Get the authenticated user", - "operationId": "13b43df400e475a157538f73617b8c3d", - "responses": { - "200": { - "description": "Authenticated user retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - }, - "401": { - "description": "Unauthenticated", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Unauthenticated." - } - }, - "type": "object" - } - } - } - } - } - } - }, - "/api/v1/profiles": { - "get": { - "tags": [ - "Profile" - ], - "summary": "List profiles", - "description": "Returns a paginated list of profiles, including their skills and projects.", - "operationId": "profileIndex", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "Page number", - "required": false, - "schema": { - "type": "integer", - "example": 1 - } - } - ], - "responses": { - "200": { - "description": "Paginated list of profiles", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Profile" - } - }, - "current_page": { - "type": "integer", - "example": 1 - }, - "last_page": { - "type": "integer", - "example": 5 - }, - "per_page": { - "type": "integer", - "example": 20 - }, - "total": { - "type": "integer", - "example": 97 - } - }, - "type": "object" - } - } - } - } - } - }, - "post": { - "tags": [ - "Profile" - ], - "summary": "Create a profile", - "description": "Not implemented yet — currently always returns a 501 response.", - "operationId": "profileStore", - "responses": { - "501": { - "description": "Endpoint not implemented", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "This endpoint is not implemented yet. The profile creation logic is handled during user registration so far." - }, - "code": { - "type": "string", - "example": "NOT_IMPLEMENTED" - } - }, - "type": "object" - } - } - } - } - } - } - }, - "/api/v1/profiles/{profile}": { - "get": { - "tags": [ - "Profile" - ], - "summary": "Get a single profile", - "description": "Returns a profile (route-model-bound by ID) along with its skills and projects.", - "operationId": "profileShow", - "parameters": [ - { - "name": "profile", - "in": "path", - "description": "ID of the profile to retrieve", - "required": true, - "schema": { - "type": "integer", - "example": 1 - } - } - ], - "responses": { - "200": { - "description": "Profile retrieved successfully", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Profile" - } - }, - "type": "object" - } - } - } - }, - "404": { - "description": "Profile not found", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "No query results for model [App\\Models\\Profile]." - } - }, - "type": "object" - } - } - } - } - } - }, - "delete": { - "tags": [ - "Profile" - ], - "summary": "Delete a profile", - "description": "Not implemented yet — currently always returns a 501 response.", - "operationId": "profileDestroy", - "parameters": [ - { - "name": "profile", - "in": "path", - "description": "ID of the profile to delete", - "required": true, - "schema": { - "type": "integer", - "example": 1 - } - } - ], - "responses": { - "501": { - "description": "Endpoint not implemented", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "This endpoint is not implemented yet." - }, - "code": { - "type": "string", - "example": "NOT_IMPLEMENTED" - } - }, - "type": "object" - } - } - } - } - } - }, - "patch": { - "tags": [ - "Profile" - ], - "summary": "Update a profile", - "description": "Validates the request via UpdateProfileRequest and updates the given profile.", - "operationId": "profileUpdate", - "parameters": [ - { - "name": "profile", - "in": "path", - "description": "ID of the profile to update", - "required": true, - "schema": { - "type": "integer", - "example": 1 - } - } - ], - "requestBody": { - "description": "Fields to update on the profile. TODO: replace with the actual fields from UpdateProfileRequest::rules().", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProfileRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Profile updated successfully", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Profile" - } - }, - "type": "object" - } - } - } - }, - "422": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "The given data was invalid." - }, - "errors": { - "type": "object" - } - }, - "type": "object" - } - } - } - }, - "404": { - "description": "Profile not found", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "No query results for model [App\\Models\\Profile]." - } - }, - "type": "object" - } - } - } - } - } - } - }, - "/api/v1/profiles/{profile}/validate": { - "post": { - "tags": [ - "Profile" - ], - "summary": "Validate a profile", - "description": "Validates the request via ValidateProfileRequest and runs profile validation logic (e.g. moderation/completeness check).", - "operationId": "profileValidate", - "parameters": [ - { - "name": "profile", - "in": "path", - "description": "ID of the profile to validate", - "required": true, - "schema": { - "type": "integer", - "example": 1 - } - } - ], - "requestBody": { - "description": "Fields required to validate the profile. TODO: replace with the actual fields from ValidateProfileRequest::rules().", - "required": true, - "content": { - "application/json": { - "schema": { - "properties": { - "status": { - "type": "string", - "example": "approved" - } - }, - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "Profile validated successfully", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Profile" - } - }, - "type": "object" - } - } - } - }, - "422": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "The given data was invalid." - }, - "errors": { - "type": "object" - } - }, - "type": "object" - } - } - } - }, - "404": { - "description": "Profile not found", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "No query results for model [App\\Models\\Profile]." - } - }, - "type": "object" - } - } - } - } - } - } - }, - "/skills": { - "get": { - "tags": [ - "Skills" - ], - "summary": "List all skills", - "operationId": "d6396076c036a31691bc99282387f12a", - "parameters": [ - { - "name": "page", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated list of skills", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SkillList" - } - }, - "links": { - "type": "object" - }, - "meta": { - "type": "object" - } - }, - "type": "object" - } - } - } - } - } - }, - "post": { - "tags": [ - "Skills" - ], - "summary": "Create a new skill", - "operationId": "a8068d5934bbdce5b106290c947357bb", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "example": "Laravel" - } - }, - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Skill created", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Skill" - } - }, - "type": "object" - } - } - } - }, - "422": { - "description": "Validation error" - } - } - } - }, - "/skills/{id}": { - "get": { - "tags": [ - "Skills" - ], - "summary": "Get a single skill", - "operationId": "d3cd4a49c570ea0205a12163e7ce589d", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Skill found", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Skill" - } - }, - "type": "object" - } - } - } - }, - "404": { - "description": "Skill not found" - } - } - }, - "put": { - "tags": [ - "Skills" - ], - "summary": "Update a skill", - "operationId": "2174910c895206bb610d6a822a0571ee", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "properties": { - "name": { - "type": "string", - "example": "Laravel" - } - }, - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "Skill updated", - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Skill" - } - }, - "type": "object" - } - } - } - }, - "404": { - "description": "Skill not found" - }, - "422": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "The given data was invalid." - }, - "code": { - "type": "string", - "example": "VALIDATION_ERROR" - } - }, - "type": "object" - } - } - } - } - } - }, - "delete": { - "tags": [ - "Skills" - ], - "summary": "Delete a skill", - "operationId": "8f01e3e7bb526bf4938d4e1d8d5285fb", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "204": { - "description": "Skill deleted successfully" - }, - "404": { - "description": "Skill not found" - } - } - } - }, - "/api/v1/users/{user}": { - "get": { - "tags": [ - "Users" - ], - "summary": "Get a single user with their profile", - "description": "Returns a user (route-model-bound by ID) along with their related profile.", - "operationId": "getUserById", - "parameters": [ - { - "name": "user", - "in": "path", - "description": "ID of the user to retrieve", - "required": true, - "schema": { - "type": "uuid", - "example": "123e4567-e89b-12d3-a456-426614174000" - } - } - ], - "responses": { - "200": { - "description": "Authenticated user retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - }, - "404": { - "description": "User not found", - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "User not found." - }, - "code": { - "type": "string", - "example": "MODEL_NOT_FOUND" - } - }, - "type": "object" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "ProfileAccountStatus": { - "title": "Profile Account Status", - "description": "The status of the profile", - "properties": { - "PENDING_VALIDATION": { - "description": "The profile is pending validation", - "type": "string" - }, - "VALIDATED": { - "description": "The profile is validated", - "type": "string" - }, - "REJECTED": { - "description": "The profile is rejected", - "type": "string" - } - }, - "type": "string", - "enum": [ - "PENDING_VALIDATION", - "VALIDATED", - "REJECTED" - ] - }, - "UserRole": { - "title": "User Role", - "description": "The role of the user", - "properties": { - "USER": { - "description": "A regular user", - "type": "string" - }, - "ADMIN": { - "description": "An administrator", - "type": "string" - } - }, - "type": "string", - "enum": [ - "User", - "Admin" - ] - }, - "UpdateProfileRequest": { - "title": "Update Profile Request", - "description": "Request payload for updating a user profile", - "properties": { - "name": { - "description": "The profile name", - "type": "string", - "example": "John Doe" - }, - "bio": { - "description": "The profile bio", - "type": "string", - "example": "Product designer based in Lille." - }, - "headline": { - "description": "The profile headline", - "type": "string", - "example": "Senior Product Designer" - }, - "location": { - "description": "The profile location", - "type": "string", - "example": "Lille, France" - }, - "skills": { - "description": "Skills linked to this profile", - "type": "array", - "items": { - "properties": { - "name": { - "description": "Skill name", - "type": "string", - "example": "Laravel" - }, - "proficiency": { - "description": "Skill proficiency level (1-5)", - "type": "integer", - "example": 4 - }, - "years_experience": { - "description": "Years of experience with the skill", - "type": "integer", - "example": 3 - } - }, - "type": "object" - } - }, - "projects": { - "description": "Projects linked to this profile", - "type": "array", - "items": { - "properties": { - "id": { - "description": "Project ID (nullable for new projects)", - "type": "integer", - "example": null, - "nullable": true - }, - "title": { - "description": "Project title", - "type": "string", - "example": "Portfolio Website" - }, - "description": { - "description": "Project description (nullable)", - "type": "string", - "example": "A personal portfolio website built with Laravel.", - "nullable": true - }, - "link": { - "description": "Project link (nullable)", - "type": "string", - "example": "https://portfolio.example.com", - "nullable": true - } - }, - "type": "object" - } - } - }, - "type": "object" - }, - "SkillList": { - "title": "SkillList", - "description": "Lightweight Skill shape used in list/index responses (no timestamps)", - "required": [ - "id", - "name", - "slug" - ], - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "name": { - "type": "string", - "example": "Laravel" - }, - "slug": { - "type": "string", - "example": "laravel" - } - }, - "type": "object" - }, - "Profile": { - "title": "Profile", - "description": "A user profile model", - "properties": { - "id": { - "description": "The profile ID", - "type": "uuid", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "user_id": { - "description": "The associated user ID", - "type": "uuid", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "email": { - "description": "The profile email", - "type": "string" - }, - "name": { - "description": "The profile name", - "type": "string" - }, - "linkedin_id": { - "description": "The profile LinkedIn ID", - "type": "string" - }, - "headline": { - "description": "The profile headline", - "type": "string" - }, - "bio": { - "description": "The profile bio", - "type": "string" - }, - "avatar_url": { - "description": "The profile avatar URL", - "type": "string" - }, - "location": { - "description": "The profile location", - "type": "string" - }, - "status": { - "description": "The profile status", - "type": "string" - }, - "account_status": { - "$ref": "#/components/schemas/ProfileAccountStatus" - }, - "skills": { - "description": "Skills linked to this profile", - "type": "array", - "items": { - "$ref": "#/components/schemas/Skill" - } - }, - "projects": { - "description": "Projects linked to this profile", - "type": "array", - "items": { - "$ref": "#/components/schemas/Project" - } - }, - "created_at": { - "description": "The creation timestamp", - "type": "string", - "format": "date-time" - }, - "updated_at": { - "description": "The update timestamp", - "type": "string", - "format": "date-time" - } - }, - "type": "object" - }, - "Project": { - "title": "Project", - "description": "A project model associated with a user profile", - "properties": { - "id": { - "description": "The project ID", - "type": "uuid", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "title": { - "description": "The project title", - "type": "string" - }, - "description": { - "description": "The project description", - "type": "string" - }, - "link": { - "description": "The project link", - "type": "string" - }, - "profile_id": { - "description": "The associated profile ID", - "type": "uuid", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "created_at": { - "description": "The creation timestamp", - "type": "string", - "format": "date-time" - }, - "updated_at": { - "description": "The update timestamp", - "type": "string", - "format": "date-time" - } - }, - "type": "object" - }, - "Skill": { - "title": "Skill", - "required": [ - "name" - ], - "properties": { - "id": { - "type": "uuid", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "name": { - "type": "string", - "example": "Laravel" - }, - "slug": { - "type": "string", - "example": "laravel" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - }, - "type": "object" - }, - "User": { - "title": "User", - "description": "A user model", - "properties": { - "id": { - "description": "The user ID", - "type": "uuid", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "name": { - "description": "The user name", - "type": "string" - }, - "email": { - "description": "The user email", - "type": "string" - }, - "avatar_url": { - "description": "The user avatar URL", - "type": "string" - }, - "linkedin_id": { - "description": "The user LinkedIn ID", - "type": "string" - }, - "email_verified_at": { - "description": "The email verification timestamp", - "type": "string", - "format": "date-time" - }, - "role": { - "$ref": "#/components/schemas/UserRole" - }, - "created_at": { - "description": "The creation timestamp", - "type": "string", - "format": "date-time" - }, - "updated_at": { - "description": "The update timestamp", - "type": "string", - "format": "date-time" - } - }, - "type": "object" - } - }, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "bearerFormat": "JWT", - "scheme": "bearer" - } - } - }, - "tags": [ - { - "name": "Auth", - "description": "Auth" - }, - { - "name": "Profile", - "description": "Profile" - }, - { - "name": "Skills", - "description": "Skills" - }, - { - "name": "Users", - "description": "Users" - } - ] -} \ No newline at end of file diff --git a/tests/Feature/Api/V1/CategoryControllerTest.php b/tests/Feature/Api/V1/CategoryControllerTest.php new file mode 100644 index 0000000..c43f7cb --- /dev/null +++ b/tests/Feature/Api/V1/CategoryControllerTest.php @@ -0,0 +1,227 @@ +create()); + } + + #[Test] + public function test_categories_require_authentication(): void + { + $this->app['auth']->forgetGuards(); + + $this->getJson('/api/v1/categories')->assertUnauthorized(); + $this->postJson('/api/v1/categories', ['name' => 'Backend'])->assertUnauthorized(); + } + + #[Test] + public function test_can_paginate_categories(): void + { + Category::factory()->count(21)->create(); + + $response = $this->getJson('/api/v1/categories?page=2'); + + $response->assertOk() + ->assertJsonPath('current_page', 2) + ->assertJsonCount(1, 'data'); + } + + #[Test] + public function test_can_create_category_with_explicit_fields(): void + { + $response = $this->postJson('/api/v1/categories', [ + 'name' => 'DevOps', + 'slug' => 'devops', + 'description' => 'Infrastructure and deployment', + 'is_active' => false, + 'sort_order' => 10, + ]); + + $response->assertCreated() + ->assertJsonPath('data.slug', 'devops') + ->assertJsonPath('data.is_active', false) + ->assertJsonPath('data.sort_order', 10); + } + + #[Test] + public function test_can_list_categories(): void + { + Category::factory()->create(['name' => 'Backend', 'slug' => 'backend', 'is_active' => true, 'sort_order' => 1]); + Category::factory()->create(['name' => 'Frontend', 'slug' => 'frontend', 'is_active' => true, 'sort_order' => 2]); + Category::factory()->create(['name' => 'Inactive', 'slug' => 'inactive', 'is_active' => false, 'sort_order' => 3]); + + $response = $this->getJson('/api/v1/categories'); + + $response->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.name', 'Backend') + ->assertJsonPath('data.1.name', 'Frontend') + ->assertJsonPath('data.2.name', 'Inactive') + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'name', 'slug', 'description', 'is_active', 'sort_order'], + ], + ]); + } + + #[Test] + public function test_can_create_category(): void + { + $payload = [ + 'name' => 'Backend', + 'description' => 'Server-side development', + 'sort_order' => 1, + ]; + + $response = $this->postJson('/api/v1/categories', $payload); + + $response->assertCreated() + ->assertJsonPath('data.name', 'Backend') + ->assertJsonPath('data.slug', 'backend') + ->assertJsonPath('data.description', 'Server-side development') + ->assertJsonPath('data.is_active', true) + ->assertJsonPath('data.sort_order', 1); + + $this->assertDatabaseHas('categories', [ + 'name' => 'Backend', + 'slug' => 'backend', + ]); + } + + #[Test] + public function test_creating_category_requires_name(): void + { + $response = $this->postJson('/api/v1/categories', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + } + + #[Test] + public function test_creating_category_fails_with_duplicate_slug(): void + { + Category::factory()->create(['slug' => 'backend']); + + $response = $this->postJson('/api/v1/categories', [ + 'name' => 'Backend', + 'slug' => 'backend', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['slug']); + } + + #[Test] + public function test_can_show_category(): void + { + $category = Category::factory()->create(['name' => 'Backend', 'slug' => 'backend']); + + $response = $this->getJson("/api/v1/categories/{$category->id}"); + + $response->assertOk() + ->assertJsonPath('data.id', $category->id) + ->assertJsonPath('data.name', 'Backend'); + } + + #[Test] + public function test_returns_404_for_missing_category(): void + { + $response = $this->getJson('/api/v1/categories/999'); + + $response->assertNotFound(); + } + + #[Test] + public function test_can_update_category(): void + { + $category = Category::factory()->create(['name' => 'Old Name', 'slug' => 'old-name']); + + $response = $this->putJson("/api/v1/categories/{$category->id}", [ + 'name' => 'New Name', + 'is_active' => false, + 'sort_order' => 5, + ]); + + $response->assertOk() + ->assertJsonPath('data.name', 'New Name') + ->assertJsonPath('data.is_active', false) + ->assertJsonPath('data.sort_order', 5); + + $this->assertDatabaseHas('categories', [ + 'id' => $category->id, + 'name' => 'New Name', + 'is_active' => false, + 'sort_order' => 5, + ]); + } + + #[Test] + public function test_updating_category_fails_with_duplicate_slug(): void + { + Category::factory()->create(['slug' => 'backend']); + $category = Category::factory()->create(['slug' => 'frontend']); + + $response = $this->putJson("/api/v1/categories/{$category->id}", [ + 'slug' => 'backend', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['slug']); + } + + #[Test] + public function test_returns_404_when_updating_missing_category(): void + { + $response = $this->putJson('/api/v1/categories/999', ['name' => 'Missing']); + + $response->assertNotFound(); + } + + #[Test] + public function test_returns_404_when_deleting_missing_category(): void + { + $response = $this->deleteJson('/api/v1/categories/999'); + + $response->assertNotFound(); + } + + #[Test] + public function test_can_update_category_description(): void + { + $category = Category::factory()->create(['description' => null]); + + $response = $this->putJson("/api/v1/categories/{$category->id}", [ + 'description' => 'Updated description', + ]); + + $response->assertOk() + ->assertJsonPath('data.description', 'Updated description'); + } + + #[Test] + public function test_can_delete_category(): void + { + $category = Category::factory()->create(); + + $response = $this->deleteJson("/api/v1/categories/{$category->id}"); + + $response->assertNoContent(); + + $this->assertSoftDeleted('categories', ['id' => $category->id]); + } +} diff --git a/tests/Feature/Api/V1/ProfileControllerTest.php b/tests/Feature/Api/V1/ProfileControllerTest.php index 11c1293..92b51f1 100644 --- a/tests/Feature/Api/V1/ProfileControllerTest.php +++ b/tests/Feature/Api/V1/ProfileControllerTest.php @@ -2,16 +2,129 @@ namespace Tests\Feature\Api\V1; -use App\Enums\UserRole; -use App\Models\Profile; -use App\Models\User; +use App\Enums\{ ProfileAccountStatus, UserRole }; +use App\Models\{ Category, Profile, User }; use Illuminate\Foundation\Testing\RefreshDatabase; +use Laravel\Sanctum\Sanctum; +use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; class ProfileControllerTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + Sanctum::actingAs(User::factory()->create()); + } + + public function test_can_list_profiles(): void + { + Profile::factory()->count(2)->create(); + + $response = $this->getJson('/api/v1/profiles'); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'name', 'email'], + ], + ]); + } + + public function test_can_show_profile_with_relations(): void + { + $category = Category::factory()->create(); + $profile = Profile::factory()->create(['category_id' => $category->id]); + + $response = $this->getJson("/api/v1/profiles/{$profile->id}"); + + $response->assertOk() + ->assertJsonPath('data.id', $profile->id) + ->assertJsonPath('data.category.id', $category->id); + } + + public function test_show_returns_404_for_missing_profile(): void + { + $response = $this->getJson('/api/v1/profiles/00000000-0000-0000-0000-000000000000'); + + $response->assertNotFound(); + } + + public function test_can_update_profile(): void + { + $category = Category::factory()->create(); + $profile = Profile::factory()->create(['name' => 'Old Name']); + + $response = $this->patchJson("/api/v1/profiles/{$profile->id}", [ + 'name' => 'New Name', + 'bio' => 'Updated bio', + 'category_id' => $category->id, + ]); + + $response->assertOk() + ->assertJsonPath('data.name', 'New Name') + ->assertJsonPath('data.bio', 'Updated bio') + ->assertJsonPath('data.category_id', $category->id); + } + + public function test_update_profile_requires_name(): void + { + $profile = Profile::factory()->create(); + + $response = $this->patchJson("/api/v1/profiles/{$profile->id}", [ + 'bio' => 'Only bio', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + } + + public function test_can_validate_profile(): void + { + $profile = Profile::factory()->create([ + 'account_status' => ProfileAccountStatus::PENDING_VALIDATION, + ]); + + $response = $this->putJson("/api/v1/profiles/{$profile->id}/validate", [ + 'account_status' => ProfileAccountStatus::VALIDATED->value, + ]); + + $response->assertOk() + ->assertJsonPath('data.account_status', ProfileAccountStatus::VALIDATED->value); + } + + public function test_validate_profile_requires_account_status(): void + { + $profile = Profile::factory()->create(); + + $response = $this->putJson("/api/v1/profiles/{$profile->id}/validate", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['account_status']); + } + + public function test_store_returns_not_implemented(): void + { + $response = $this->postJson('/api/v1/profiles', []); + + $response->assertStatus(501) + ->assertJsonPath('code', 'NOT_IMPLEMENTED'); + } + + public function test_destroy_returns_not_implemented(): void + { + $profile = Profile::factory()->create(); + + $response = $this->deleteJson("/api/v1/profiles/{$profile->id}"); + + $response->assertStatus(501) + ->assertJsonPath('code', 'NOT_IMPLEMENTED'); + } + public function test_update_profile_updates_allowed_fields(): void { $user = User::factory()->has(Profile::factory())->create(['role' => UserRole::Admin]); @@ -23,6 +136,4 @@ public function test_update_profile_updates_allowed_fields(): void $response->assertStatus(200); } - - } diff --git a/tests/Unit/Models/CategoryTest.php b/tests/Unit/Models/CategoryTest.php new file mode 100644 index 0000000..14d2df2 --- /dev/null +++ b/tests/Unit/Models/CategoryTest.php @@ -0,0 +1,87 @@ +create([ + 'name' => 'Backend', + 'slug' => 'backend', + 'is_active' => true, + 'sort_order' => 1, + ]); + + $this->assertDatabaseHas('categories', [ + 'id' => $category->id, + 'name' => 'Backend', + 'slug' => 'backend', + 'sort_order' => 1, + ]); + } + + #[Test] + public function test_is_active_is_cast_to_boolean(): void + { + $category = Category::factory()->create(['is_active' => 1]); + + $this->assertTrue($category->is_active); + $this->assertIsBool($category->is_active); + } + + #[Test] + public function test_can_soft_delete_category(): void + { + $category = Category::factory()->create(); + + $category->delete(); + + $this->assertSoftDeleted($category); + $this->assertNull(Category::query()->find($category->id)); + $this->assertNotNull(Category::withTrashed()->find($category->id)); + } + + #[Test] + public function test_profile_belongs_to_category(): void + { + $category = Category::factory()->create(); + $profile = Profile::factory()->create(['category_id' => $category->id]); + + $this->assertTrue($profile->category->is($category)); + $this->assertTrue($category->profiles->contains($profile)); + } + + #[Test] + public function test_profiles_relation(): void + { + $category = Category::factory()->create(); + + $relation = $category->profiles(); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame(Profile::class, $relation->getRelated()::class); + } + + #[Test] + public function test_projects_relation(): void + { + $category = Category::factory()->create(); + + $relation = $category->projects(); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame(Project::class, $relation->getRelated()::class); + } +} diff --git a/tests/Unit/Services/ProfileServiceTest.php b/tests/Unit/Services/ProfileServiceTest.php index bbd1f25..628ef45 100644 --- a/tests/Unit/Services/ProfileServiceTest.php +++ b/tests/Unit/Services/ProfileServiceTest.php @@ -5,6 +5,7 @@ use App\Models\Profile; use App\Models\Project; use App\Models\Skill; +use App\Models\Category; use App\Services\ProfileService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; @@ -77,6 +78,63 @@ public function it_returns_the_profile_with_skills_and_projects_eager_loaded(): $this->assertTrue($result->relationLoaded('skills')); $this->assertTrue($result->relationLoaded('projects')); + $this->assertTrue($result->relationLoaded('category')); + } + + #[Test] + public function it_assigns_a_category_when_category_id_is_provided(): void + { + $profile = Profile::factory()->create(['category_id' => null]); + $category = Category::factory()->create(['name' => 'Backend', 'slug' => 'backend']); + + $result = $this->profileService->updateProfile($profile, [ + 'name' => 'Developer', + 'category_id' => $category->id, + ]); + + $this->assertSame($category->id, $result->category_id); + $this->assertNotNull($result->category); + $this->assertSame('Backend', $result->category->name); + + $this->assertDatabaseHas('profiles', [ + 'id' => $profile->id, + 'category_id' => $category->id, + ]); + } + + #[Test] + public function it_clears_the_category_when_category_id_is_null(): void + { + $category = Category::factory()->create(); + $profile = Profile::factory()->create(['category_id' => $category->id]); + + $result = $this->profileService->updateProfile($profile, [ + 'name' => 'Developer', + 'category_id' => null, + ]); + + $this->assertNull($result->category_id); + + $this->assertDatabaseHas('profiles', [ + 'id' => $profile->id, + 'category_id' => null, + ]); + } + + #[Test] + public function it_does_not_touch_category_when_category_id_key_is_absent(): void + { + $category = Category::factory()->create(); + $profile = Profile::factory()->create(['category_id' => $category->id]); + + $this->profileService->updateProfile($profile, [ + 'name' => 'Updated Name', + ]); + + $this->assertDatabaseHas('profiles', [ + 'id' => $profile->id, + 'category_id' => $category->id, + ]); } #[Test]