diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ebbef53 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + name: PHPUnit (Laravel) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: mbstring, pdo, pdo_sqlite, sqlite3, bcmath + coverage: none + + - name: Copy .env + run: cp .env.example .env + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Generate app key + run: php artisan key:generate + + - name: Run tests + run: php artisan test diff --git a/README.md b/README.md index bafcb99..b47d3cc 100755 --- a/README.md +++ b/README.md @@ -1,164 +1,119 @@ -TaskManager – API & Plataforma de Gestión de Tareas +# ✅ TaskManager — API de Gestión de Tareas -Proyecto personal pensado para mostrar buenas prácticas en desarrollo Full‑Stack con Laravel + PostgreSQL + Docker. +API REST en **Laravel 12** para gestionar tareas con **autenticación JWT**. +Diseñada en capas (**controllers → services → repositories → models**) para +mantener el dominio desacoplado de la infraestructura. Lista para Docker y con +suite de tests en GitHub Actions. -✨ Características principales +![CI](https://github.com/AlejandroVegaFullstackDev/taskManager/actions/workflows/ci.yml/badge.svg) +![PHP](https://img.shields.io/badge/php-8.2-777BB4) +![Laravel](https://img.shields.io/badge/laravel-12-FF2D20) +![PostgreSQL](https://img.shields.io/badge/postgres-15-336791) +![Tests](https://img.shields.io/badge/tests-PHPUnit-3776AB) -API RESTful con operaciones CRUD para tareas. +--- -Autenticación JWT (Laravel Sanctum) con expiración configurable. +## ✨ Características -Arquitectura hexagonal: controladores → servicios → repositorios → modelos. +- **CRUD** de tareas vía API REST. +- **Autenticación JWT** con `tymon/jwt-auth` (register, login, refresh, logout). +- **Arquitectura en capas**: el controlador delega en un **servicio**, que usa un + **repositorio** (interfaz) — inversión de dependencias vía contenedor de Laravel. +- **Validación** con Form Requests. +- **Tests** de feature (API + auth) y unitarios (servicio) con PHPUnit. -Docker‑first: entorno replicable en cualquier máquina. +> El portafolio mencionaba "Sanctum": la autenticación real usa +> **`tymon/jwt-auth`** (tokens JWT con TTL configurable). -CI/CD listo para GitHub Actions (tests + Lint + Build). +--- -Cobertura de tests: unitarios y de integración (PHPUnit). - -🚀 Demo local en 5 pasos - -# 1. Clona el repo -$ git clone https://github.com/tu‑usuario/taskmanager.git && cd taskmanager - -# 2. Copia variables de entorno -$ cp .env.example .env # ajusta valores si lo deseas - -# 3. Levanta servicios -$ docker compose up -d --build - -# 4. Instala dependencias & ejecuta migraciones -$ docker compose exec app composer install -$ docker compose exec app php artisan migrate --seed - -# 5. Visita la app (frontend opcional) -http://localhost:3000 # si usas el front React opcional - -Credenciales iniciales (Seeds)email: admin@example.compassword: passwordPuedes cambiarlas en database/seeders/UserSeeder.php antes de levantar el stack. - -🗄️ Stack Tecnológico - -Capa - -Tecnología - -Versión - -Backend - -PHP / Laravel - -8.3 / 10.x - -Base de datos - -PostgreSQL - -15 - -Autenticación - -JWT (Sanctum) - -— - -Contenedores - -Docker & Compose - -26+ - -CI/CD - -GitHub Actions - -— - -Testing - -PHPUnit + Pest - -— - -📂 Estructura de carpetas (backend) +## 🧱 Arquitectura +``` app/ - ├─ Http/Controllers // Entradas HTTP - ├─ Domain/Models // Entidades de dominio (Eloquent) - ├─ Domain/Repositories // Interfaces - ├─ Infrastructure/Repos // Implementaciones Eloquent - └─ Services // Casos de uso - -🔐 Autenticación - -Login – POST /api/login - -{ "email": "admin@example.com", "password": "password" } - -Respuesta → access_token, token_type, expires_in. - -Incluye la cabecera: - -Authorization: Bearer - -📑 Endpoints de Tareas - -Método - -Endpoint - -Descripción - -GET - -/api/tasks - -Listar tareas +├── Http/ +│ ├── Controllers/ TaskController, AuthController (capa de entrada, delgada) +│ └── Requests/ StoreTaskRequest, UpdateTaskRequest (validación) +├── Services/ TaskService (lógica de aplicación) +├── Repositories/ TaskRepositoryInterface + TaskRepository (persistencia) +├── Exceptions/ TaskNotFoundException (render 404 JSON) +└── Models/ Task, User +``` -POST +El binding `TaskRepositoryInterface → TaskRepository` se registra en +`AppServiceProvider`, así el servicio depende de la **interfaz**, no de Eloquent. -/api/tasks +--- -Crear tarea +## 🔗 Endpoints -GET +| Método | Ruta | Auth | Descripción | +|--------|------|------|-------------| +| `POST` | `/api/register` | público | Crea usuario y devuelve token | +| `POST` | `/api/login` | público | Devuelve token JWT | +| `POST` | `/api/refresh` | JWT | Renueva el token | +| `POST` | `/api/logout` | JWT | Invalida el token | +| `GET` | `/api/tasks` | JWT | Lista tareas | +| `POST` | `/api/tasks` | JWT | Crea tarea | +| `GET` | `/api/tasks/{id}` | JWT | Detalle de tarea | +| `PUT` | `/api/tasks/{id}` | JWT | Actualiza tarea | +| `DELETE` | `/api/tasks/{id}` | JWT | Elimina tarea | -/api/tasks/{id} +Las rutas protegidas requieren `Authorization: Bearer `. El campo +`status` de una tarea acepta `pendiente` o `completada`. -Obtener tarea +```jsonc +// POST /api/tasks +{ "title": "Comprar pan", "description": "En la tienda", "status": "pendiente" } +``` -PUT +--- -/api/tasks/{id} +## 🚀 Cómo correrlo -Actualizar tarea +### Local (SQLite, rápido) -DELETE +```bash +composer install +cp .env.example .env +php artisan key:generate +php artisan jwt:secret # genera JWT_SECRET +touch database/database.sqlite # .env trae DB_CONNECTION=sqlite +php artisan migrate +php artisan serve # http://localhost:8000 +``` -/api/tasks/{id} +### Docker (PostgreSQL) -Eliminar tarea +```bash +cp .env.example .env # ajusta DB_* y pon DB_CONNECTION=pgsql +docker compose up -d --build +docker compose exec app php artisan migrate --seed +``` -Consulta docs/openapi.yaml para una especificación completa (OpenAPI 3.1). +El seeder crea un usuario de prueba `test@example.com` / `password`. -🧪 Pruebas +--- -# Ejecutar todas las pruebas -$ docker compose exec app php artisan test +## 🧪 Tests -# Cobertura (HTML) -$ docker compose exec app phpdbg -qrr vendor/bin/phpunit --coverage-html storage/coverage +```bash +php artisan test # corre la suite (SQLite en memoria, vía phpunit.xml) +``` -☁️ Despliegue en producción +Cubren registro/login/refresh, protección por JWT y el CRUD completo de tareas, +además de tests unitarios del `TaskService` con repositorio simulado (Mockery). +La suite corre en **GitHub Actions** (`.github/workflows/ci.yml`). -Se puede desplegar en cualquier PaaS que soporte Docker (AWS ECS/Fargate, Railway, Fly.io, etc.). -Ejemplo de workflow GitHub Actions a Railway incluido en .github/workflows/deploy.yml. +--- -📄 Licencia +## 🛠️ Stack -Publicado bajo la licencia MIT. Siéntete libre de usarlo como base para tus propios proyectos. +`PHP 8.2` · `Laravel 12` · `tymon/jwt-auth` · `PostgreSQL 15` / `SQLite` · +`PHPUnit 11` · `Docker` -🤝 Créditos y contexto +--- -Este repositorio nació como una prueba técnica; posteriormente fue refactorizado y ampliado para servir como ejemplo público de buenas prácticas. Todo el código mostrado aquí es 100 % original y no contiene información ni activos privados de terceros. +## 📄 Licencia +MIT diff --git a/app/Exceptions/TaskNotFoundException.php b/app/Exceptions/TaskNotFoundException.php new file mode 100644 index 0000000..0bc7e0b --- /dev/null +++ b/app/Exceptions/TaskNotFoundException.php @@ -0,0 +1,19 @@ +json(['error' => 'Tarea no encontrada'], 404); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 0d3e24f..21fd80a 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -2,40 +2,72 @@ namespace App\Http\Controllers; +use App\Models\User; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Tymon\JWTAuth\Facades\JWTAuth; class AuthController extends Controller { - public function login(Request $request) + /** + * Registra un usuario nuevo y devuelve un token JWT. + */ + public function register(Request $request): JsonResponse { - $credentials = $request->only('email', 'password'); - + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'unique:users,email'], + 'password' => ['required', 'string', 'min:6'], + ]); + + // El cast 'hashed' del modelo User se encarga de hashear la contraseña. + $user = User::create($data); + + $token = auth('api')->login($user); + + return $this->respondWithToken($token, 201); + } + + /** + * Autentica con email/password y devuelve un token JWT. + */ + public function login(Request $request): JsonResponse + { + $credentials = $request->validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + if (! $token = auth('api')->attempt($credentials)) { return response()->json(['error' => 'Credenciales inválidas'], 401); } - - return response()->json([ - 'access_token' => $token, - 'token_type' => 'bearer', - 'expires_in' => auth('api')->factory()->getTTL() * 60, - ]); + + return $this->respondWithToken($token); } - - public function logout() + /** + * Invalida el token actual. + */ + public function logout(): JsonResponse { - auth()->logout(); + auth('api')->logout(); + return response()->json(['message' => 'Sesión cerrada correctamente']); } - protected function respondWithToken($token) + /** + * Emite un nuevo token a partir del actual. + */ + public function refresh(): JsonResponse + { + return $this->respondWithToken(auth('api')->refresh()); + } + + protected function respondWithToken(string $token, int $status = 200): JsonResponse { return response()->json([ 'access_token' => $token, 'token_type' => 'bearer', - 'expires_in' => auth()->factory()->getTTL() * 60, // en segundos - ]); + 'expires_in' => auth('api')->factory()->getTTL() * 60, // segundos + ], $status); } } diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index 903129e..5cfe06a 100755 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -2,84 +2,48 @@ namespace App\Http\Controllers; -use App\Models\Task; -use App\Repositories\TaskRepositoryInterface; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; +use App\Http\Requests\StoreTaskRequest; +use App\Http\Requests\UpdateTaskRequest; +use App\Services\TaskService; +use Illuminate\Http\JsonResponse; class TaskController extends Controller { - protected $tasks; - - - public function __construct(TaskRepositoryInterface $tasks) - { - $this->tasks = $tasks; - } + public function __construct( + private readonly TaskService $tasks, + ) {} // GET /api/tasks - public function index() + public function index(): JsonResponse { - return response()->json($this->tasks->all()); + return response()->json($this->tasks->list()); } // POST /api/tasks - public function store(Request $request) + public function store(StoreTaskRequest $request): JsonResponse { - $validator = Validator::make($request->all(), [ - 'title' => 'required|string', - 'description' => 'required|string', - 'status' => 'required|in:pendiente,completada', - ]); - - if ($validator->fails()) { - return response()->json($validator->errors(), 422); - } + $task = $this->tasks->create($request->validated()); - $task = $this->tasks->create($request->all()); return response()->json($task, 201); } // GET /api/tasks/{id} - public function show($id) + public function show(int $id): JsonResponse { - $task = $this->tasks->find($id); - if (!$task) { - return response()->json(['error' => 'Tarea no encontrada'], 404); - } - return response()->json($task); + return response()->json($this->tasks->find($id)); } // PUT/PATCH /api/tasks/{id} - public function update(Request $request, $id) + public function update(UpdateTaskRequest $request, int $id): JsonResponse { - $task = $this->tasks->find($id); - if (!$task) { - return response()->json(['error' => 'Tarea no encontrada'], 404); - } - - $validator = Validator::make($request->all(), [ - 'title' => 'sometimes|required|string', - 'description' => 'sometimes|required|string', - 'status' => 'sometimes|required|in:pendiente,completada', - ]); - - if ($validator->fails()) { - return response()->json($validator->errors(), 422); - } - - $this->tasks->update($task, $request->all()); - return response()->json($task); + return response()->json($this->tasks->update($id, $request->validated())); } // DELETE /api/tasks/{id} - public function destroy($id) + public function destroy(int $id): JsonResponse { - $task = $this->tasks->find($id); - if (!$task) { - return response()->json(['error' => 'Tarea no encontrada'], 404); - } - $this->tasks->delete($task); + $this->tasks->delete($id); + return response()->json(['message' => 'Tarea eliminada correctamente']); } } diff --git a/app/Http/Requests/StoreTaskRequest.php b/app/Http/Requests/StoreTaskRequest.php new file mode 100644 index 0000000..cc20160 --- /dev/null +++ b/app/Http/Requests/StoreTaskRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['required', 'string'], + 'status' => ['required', 'in:pendiente,completada'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskRequest.php b/app/Http/Requests/UpdateTaskRequest.php new file mode 100644 index 0000000..032eb6c --- /dev/null +++ b/app/Http/Requests/UpdateTaskRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:255'], + 'description' => ['sometimes', 'required', 'string'], + 'status' => ['sometimes', 'required', 'in:pendiente,completada'], + ]; + } +} diff --git a/app/Services/TaskService.php b/app/Services/TaskService.php new file mode 100644 index 0000000..68a832b --- /dev/null +++ b/app/Services/TaskService.php @@ -0,0 +1,64 @@ +tasks->all(); + } + + public function create(array $data): Task + { + return $this->tasks->create($data); + } + + /** + * @throws TaskNotFoundException si la tarea no existe. + */ + public function find(int $id): Task + { + $task = $this->tasks->find($id); + + if (! $task) { + throw new TaskNotFoundException(); + } + + return $task; + } + + /** + * @throws TaskNotFoundException + */ + public function update(int $id, array $data): Task + { + $task = $this->find($id); + $this->tasks->update($task, $data); + + return $task->refresh(); + } + + /** + * @throws TaskNotFoundException + */ + public function delete(int $id): void + { + $task = $this->find($id); + $this->tasks->delete($task); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5bc5045..9667282 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Exceptions; use Tymon\JWTAuth\Http\Middleware\Authenticate as JWTAuthMiddleware; +use Tymon\JWTAuth\Http\Middleware\RefreshToken as JWTRefreshMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php new file mode 100644 index 0000000..684a23f --- /dev/null +++ b/database/factories/TaskFactory.php @@ -0,0 +1,26 @@ + + */ +class TaskFactory extends Factory +{ + protected $model = Task::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'title' => fake()->sentence(3), + 'description' => fake()->paragraph(), + 'status' => fake()->randomElement(['pendiente', 'completada']), + ]; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d346a64..c7f1fcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +# Los valores se leen del archivo `.env` (copia de `.env.example`). Si no existe, +# se usan los defaults de desarrollo definidos con la sintaxis ${VAR:-default}. services: app: @@ -12,17 +14,21 @@ services: depends_on: - db environment: - # Puedes agregar aquí otras variables de entorno necesarias para Laravel + DB_CONNECTION: pgsql DB_HOST: db + DB_PORT: 5432 + DB_DATABASE: ${DB_DATABASE:-taskmanagerdb} + DB_USERNAME: ${DB_USERNAME:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} db: image: postgres:15 container_name: taskmanager_db restart: always environment: - POSTGRES_DB: taskmanagerdb - POSTGRES_USER: postgres - POSTGRES_PASSWORD: nDYKAHm9kgMuUNgSiN5y + POSTGRES_DB: ${DB_DATABASE:-taskmanagerdb} + POSTGRES_USER: ${DB_USERNAME:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} ports: - "5432:5432" volumes: diff --git a/phpunit.xml b/phpunit.xml index 506b9a3..fd21eb9 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,11 +19,13 @@ + - - + + + diff --git a/routes/api.php b/routes/api.php index d80b9b1..8d47563 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\TaskController; // Rutas públicas +Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); // Rutas protegidas por JWT diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php new file mode 100644 index 0000000..c0c7da4 --- /dev/null +++ b/tests/Feature/AuthTest.php @@ -0,0 +1,58 @@ +postJson('/api/register', [ + 'name' => 'Ana', + 'email' => 'ana@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(201) + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']); + + $this->assertDatabaseHas('users', ['email' => 'ana@example.com']); + } + + public function test_login_with_valid_credentials_returns_token(): void + { + // UserFactory crea el usuario con la contraseña 'password'. + User::factory()->create(['email' => 'ana@example.com']); + + $response = $this->postJson('/api/login', [ + 'email' => 'ana@example.com', + 'password' => 'password', + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']); + } + + public function test_login_with_invalid_credentials_returns_401(): void + { + User::factory()->create(['email' => 'ana@example.com']); + + $response = $this->postJson('/api/login', [ + 'email' => 'ana@example.com', + 'password' => 'incorrecta', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['error' => 'Credenciales inválidas']); + } + + public function test_protected_route_requires_token(): void + { + $this->getJson('/api/tasks')->assertStatus(401); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100755 index 8364a84..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Feature/TaskApiTest.php b/tests/Feature/TaskApiTest.php new file mode 100644 index 0000000..4e632d5 --- /dev/null +++ b/tests/Feature/TaskApiTest.php @@ -0,0 +1,119 @@ + + */ + private function authHeaders(): array + { + $token = JWTAuth::fromUser(User::factory()->create()); + + return ['Authorization' => "Bearer {$token}"]; + } + + public function test_requires_authentication(): void + { + $this->getJson('/api/tasks')->assertStatus(401); + } + + public function test_lists_tasks(): void + { + Task::factory()->count(3)->create(); + + $this->withHeaders($this->authHeaders()) + ->getJson('/api/tasks') + ->assertStatus(200) + ->assertJsonCount(3); + } + + public function test_creates_task(): void + { + $payload = [ + 'title' => 'Comprar pan', + 'description' => 'En la tienda de la esquina', + 'status' => 'pendiente', + ]; + + $this->withHeaders($this->authHeaders()) + ->postJson('/api/tasks', $payload) + ->assertStatus(201) + ->assertJsonFragment(['title' => 'Comprar pan']); + + $this->assertDatabaseHas('tasks', ['title' => 'Comprar pan']); + } + + public function test_create_fails_validation_when_incomplete(): void + { + $this->withHeaders($this->authHeaders()) + ->postJson('/api/tasks', ['title' => 'Sólo título']) + ->assertStatus(422); + } + + public function test_create_rejects_invalid_status(): void + { + $this->withHeaders($this->authHeaders()) + ->postJson('/api/tasks', [ + 'title' => 'Tarea', + 'description' => 'desc', + 'status' => 'en_progreso', // no permitido + ]) + ->assertStatus(422); + } + + public function test_shows_a_task(): void + { + $task = Task::factory()->create(); + + $this->withHeaders($this->authHeaders()) + ->getJson("/api/tasks/{$task->id}") + ->assertStatus(200) + ->assertJsonFragment(['id' => $task->id]); + } + + public function test_returns_404_for_missing_task(): void + { + $this->withHeaders($this->authHeaders()) + ->getJson('/api/tasks/9999') + ->assertStatus(404) + ->assertJsonFragment(['error' => 'Tarea no encontrada']); + } + + public function test_updates_a_task(): void + { + $task = Task::factory()->create(['status' => 'pendiente']); + + $this->withHeaders($this->authHeaders()) + ->putJson("/api/tasks/{$task->id}", ['status' => 'completada']) + ->assertStatus(200) + ->assertJsonFragment(['status' => 'completada']); + + $this->assertDatabaseHas('tasks', [ + 'id' => $task->id, + 'status' => 'completada', + ]); + } + + public function test_deletes_a_task(): void + { + $task = Task::factory()->create(); + + $this->withHeaders($this->authHeaders()) + ->deleteJson("/api/tasks/{$task->id}") + ->assertStatus(200); + + $this->assertDatabaseMissing('tasks', ['id' => $task->id]); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100755 index 5773b0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/Unit/TaskServiceTest.php b/tests/Unit/TaskServiceTest.php new file mode 100644 index 0000000..a02c736 --- /dev/null +++ b/tests/Unit/TaskServiceTest.php @@ -0,0 +1,53 @@ +shouldReceive('find')->once()->with(99)->andReturn(null); + + $this->expectException(TaskNotFoundException::class); + + (new TaskService($repo))->find(99); + } + + public function test_create_delegates_to_the_repository(): void + { + $task = Mockery::mock(Task::class); + $repo = Mockery::mock(TaskRepositoryInterface::class); + $repo->shouldReceive('create')->once()->with(['title' => 't'])->andReturn($task); + + $result = (new TaskService($repo))->create(['title' => 't']); + + $this->assertSame($task, $result); + } + + public function test_delete_removes_existing_task(): void + { + $task = Mockery::mock(Task::class); + $repo = Mockery::mock(TaskRepositoryInterface::class); + $repo->shouldReceive('find')->once()->with(1)->andReturn($task); + $repo->shouldReceive('delete')->once()->with($task)->andReturnTrue(); + + (new TaskService($repo))->delete(1); + + // Si no se lanzó excepción y se cumplieron las expectativas, pasa. + $this->assertTrue(true); + } +}