Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d5ae586
feat: add Email ValueObject to User domain
zigzagdev Jul 12, 2026
08c6550
feat: add findByEmail to UserRepositroyInterface
zigzagdev Jul 12, 2026
172e358
refactor: update UserEntityFactory to use Email ValueObject
zigzagdev Jul 12, 2026
92fa72e
refactor: update UserEntity to use Email ValueObject
zigzagdev Jul 12, 2026
435dc90
feat: implement findByEmail in UserRepository
zigzagdev Jul 12, 2026
2fabbf3
test: add findByEmail integration tests to UserRepositoryTest
zigzagdev Jul 12, 2026
0657750
fix: update UserEntityFactoryTest to call getEmail()->value() after E…
zigzagdev Jul 13, 2026
f4f1f88
fix: update UserEntityTest to wrap email string in Email VO
zigzagdev Jul 13, 2026
6fbefe0
Merge pull request #530 from zigzagdev/feat/user-login-domain-infra-f…
zigzagdev Jul 13, 2026
c931f74
feat: install laravel/sanctum
zigzagdev Jul 13, 2026
918f1b7
feat: add personal_access_tokens migration for Sanctum
zigzagdev Jul 13, 2026
4fa03dd
feat: add HasApiTokens trait to User model
zigzagdev Jul 13, 2026
f913adb
feat: add TokenServiceInterface to User domain
zigzagdev Jul 13, 2026
26da6b1
feat: implement SanctumTokenService
zigzagdev Jul 13, 2026
7f686de
feat: bind TokenServiceInterface to SanctumTokenService in AppService…
zigzagdev Jul 13, 2026
582e539
test: add integration tests for SanctumTokenService
zigzagdev Jul 13, 2026
743bb12
feat: publish Sanctum config and set token expiration to 1 day
zigzagdev Jul 13, 2026
824018e
Merge pull request #531 from zigzagdev/feat/user-login_infra-layer-to…
zigzagdev Jul 13, 2026
3243004
feat: add AuthTokenDto with token and token_type fields
zigzagdev Jul 13, 2026
868800e
refactor: rename TokenDto to AuthTokenDto and update LoginUseCase and…
zigzagdev Jul 13, 2026
f382085
Merge pull request #533 from zigzagdev/feat/user-login_application-layer
zigzagdev Jul 13, 2026
1fa30f4
feat: add AuthController with login action
zigzagdev Jul 13, 2026
449f04c
feat: register POST /v1/user/login route and refactor routes with Rou…
zigzagdev Jul 13, 2026
17259ce
test: add integration tests for POST /api/v1/user/login
zigzagdev Jul 13, 2026
84811c8
feat: add mago.toml with lint-based static analysis config
zigzagdev Jul 13, 2026
44d0ba0
feat: add static_analysis.yml CI workflow for mago lint
zigzagdev Jul 13, 2026
50d4017
chore: add .composer to .gitignore
zigzagdev Jul 13, 2026
b75a51f
chore: tune mago.toml lint scope and rule thresholds
zigzagdev Jul 13, 2026
e53fed5
refactor: remove final from LoginUseCase to allow mocking in tests
zigzagdev Jul 14, 2026
16fc4df
test: add 500 error case for POST /api/v1/user/login
zigzagdev Jul 14, 2026
01ba6d8
Merge pull request #534 from zigzagdev/feat/user-login_presentation-l…
zigzagdev Jul 14, 2026
e24f4c2
test: add unit tests for Email ValueObject
zigzagdev Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/static_analysis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Static Analysis

on:
pull_request:
types: [opened, synchronize]
paths:
- "src/**/*.php"
workflow_dispatch: {}

jobs:
mago:
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
coverage: none
tools: composer

- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ~/.cache/composer
key: ${{ runner.os }}-composer-${{ hashFiles('src/composer.lock') }}

- name: Install dependencies
run: composer install --prefer-dist --no-progress
working-directory: ./src

- name: Setup Mago
uses: nhedger/setup-mago@v1

- name: Run Mago lint
run: ./vendor/bin/mago lint
working-directory: ./src
1 change: 1 addition & 0 deletions src/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/.phpunit.cache
/.composer
/node_modules
/public/build
/public/hot
Expand Down
2 changes: 2 additions & 0 deletions src/app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
use HasApiTokens;
protected $table = 'users';

protected $fillable = [
Expand Down
3 changes: 2 additions & 1 deletion src/app/Packages/Domains/User/Factory/UserEntityFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Packages\Domains\User\Factory\SubscriptionFactory;
use App\Packages\Domains\User\UserEntity;
use App\Packages\Domains\User\AgeRange;
use App\Packages\Domains\User\ValueObject\Email;

final class UserEntityFactory
{
Expand All @@ -20,7 +21,7 @@ public static function build(array $data): UserEntity
id: isset($data['id']) ? (int) $data['id'] : null,
firstName: $data['first_name'],
lastName: $data['last_name'],
email: $data['email'],
email: new Email($data['email']),
ageRange: AgeRange::from($data['age_range']),
subscription: $subscription,
passwordHash: $data['password_hash'] ?? null,
Expand Down
12 changes: 12 additions & 0 deletions src/app/Packages/Domains/User/Interface/TokenServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace App\Packages\Domains\User\Interface;

use App\Packages\Domains\User\UserEntity;

interface TokenServiceInterface
{
public function createToken(UserEntity $entity): string;

public function revokeAllTokens(UserEntity $entity): void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Packages\Domains\User\UserEntity;
use App\Packages\Features\QueryUseCases\Dto\User\UserDto;
use App\Packages\Domains\User\ValueObject\Email;

interface UserRepositroyInterface
{
Expand All @@ -14,4 +15,6 @@ public function findById(int $id): ?UserDto;
public function updateUser(UserEntity $entity): UserDto;

public function deleteUser(int $id): void;

public function findByEmail(Email $email): ?UserEntity;
}
33 changes: 33 additions & 0 deletions src/app/Packages/Domains/User/Service/SanctumTokenService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Packages\Domains\User\Service;

use App\Models\User;
use App\Packages\Domains\User\Interface\TokenServiceInterface;
use App\Packages\Domains\User\UserEntity;
use RuntimeException;

class SanctumTokenService implements TokenServiceInterface
{
public function createToken(UserEntity $entity): string
{
$user = User::find($entity->getId());

if ($user === null) {
throw new RuntimeException('User not found.');
}

return $user->createToken('auth-token')->plainTextToken;
}

public function revokeAllTokens(UserEntity $entity): void
{
$user = User::find($entity->getId());

if ($user === null) {
throw new RuntimeException('User not found.');
}

$user->tokens()->delete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function test_build_returns_user_entity_with_correct_values(): void
$this->assertSame($data['id'], $entity->getId());
$this->assertSame($data['first_name'], $entity->getFirstName());
$this->assertSame($data['last_name'], $entity->getLastName());
$this->assertSame($data['email'], $entity->getEmail());
$this->assertSame($data['email'], $entity->getEmail()->value());
$this->assertSame(AgeRange::Teens, $entity->getAgeRange());
$this->assertTrue($entity->getSubscription()->isFree());
$this->assertNull($entity->getPasswordHash());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

namespace App\Packages\Domains\User\Tests\Service;

use App\Models\User;
use App\Packages\Domains\User\Factory\UserEntityFactory;
use App\Packages\Domains\User\Service\SanctumTokenService;
use App\Packages\Domains\User\UserEntity;
use Faker\Factory as FakerFactory;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class SanctumTokenServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->truncate();
}

protected function tearDown(): void
{
$this->truncate();
parent::tearDown();
}

private function truncate(): void
{
if (env('APP_ENV') === 'testing') {
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
DB::connection('mysql')->table('personal_access_tokens')->truncate();
User::truncate();
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
}
}

private function service(): SanctumTokenService
{
return new SanctumTokenService();
}

private function seedUser(array $overrides = []): User
{
$faker = FakerFactory::create();

return User::create(array_merge([
'first_name' => $faker->firstName(),
'last_name' => $faker->lastName(),
'email' => $faker->unique()->safeEmail(),
'password' => bcrypt('password'),
'age_range' => 'teens',
'subscription_tier' => 'free',
'subscription_expires_at' => null,
], $overrides));
}

private function buildEntityFromUser(User $user): UserEntity
{
return UserEntityFactory::build([
'id' => $user->id,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'email' => $user->email,
'age_range' => $user->age_range,
'subscription_tier' => $user->subscription_tier,
'subscription_expires_at' => $user->subscription_expires_at,
'password_hash' => $user->password,
]);
}

public function test_createToken_returns_plain_text_token(): void
{
$user = $this->seedUser();
$entity = $this->buildEntityFromUser($user);

$token = $this->service()->createToken($entity);

$this->assertIsString($token);
$this->assertNotEmpty($token);
$this->assertDatabaseHas('personal_access_tokens', [
'tokenable_id' => $user->id,
'tokenable_type' => User::class,
]);
}

public function test_createToken_throws_when_user_not_found(): void
{
$entity = UserEntityFactory::build([
'id' => 999999,
'first_name' => 'Ghost',
'last_name' => 'User',
'email' => 'ghost@example.com',
'age_range' => 'teens',
'subscription_tier' => 'free',
'subscription_expires_at' => null,
]);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('User not found.');

$this->service()->createToken($entity);
}

public function test_revokeAllTokens_deletes_tokens_for_user(): void
{
$user = $this->seedUser();
$entity = $this->buildEntityFromUser($user);

$user->createToken('token-1');
$user->createToken('token-2');

$this->service()->revokeAllTokens($entity);

$this->assertDatabaseMissing('personal_access_tokens', [
'tokenable_id' => $user->id,
]);
}

public function test_revokeAllTokens_throws_when_user_not_found(): void
{
$entity = UserEntityFactory::build([
'id' => 999999,
'first_name' => 'Ghost',
'last_name' => 'User',
'email' => 'ghost@example.com',
'age_range' => 'teens',
'subscription_tier' => 'free',
'subscription_expires_at' => null,
]);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('User not found.');

$this->service()->revokeAllTokens($entity);
}
}
5 changes: 3 additions & 2 deletions src/app/Packages/Domains/User/Tests/UserEntityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Packages\Domains\User\Subscription\Subscription;
use App\Packages\Domains\User\Subscription\SubscriptionTier;
use App\Packages\Domains\User\UserEntity;
use App\Packages\Domains\User\ValueObject\Email;
use Faker\Factory as FakerFactory;
use PHPUnit\Framework\TestCase;

Expand All @@ -20,7 +21,7 @@ private function buildEntity(array $overrides = []): UserEntity
id: array_key_exists('id', $overrides) ? $overrides['id'] : $faker->unique()->randomNumber(5),
firstName: $overrides['first_name'] ?? $faker->firstName(),
lastName: $overrides['last_name'] ?? $faker->lastName(),
email: $overrides['email'] ?? $faker->unique()->safeEmail(),
email: isset($overrides['email']) ? new Email($overrides['email']) : new Email($faker->unique()->safeEmail()),
ageRange: $overrides['age_range'] ?? AgeRange::Twenties,
subscription: $overrides['subscription'] ?? $subscription,
passwordHash: $overrides['password_hash'] ?? null,
Expand Down Expand Up @@ -60,7 +61,7 @@ public function test_getFullName_concatenates_first_and_last_name(): void
public function test_getEmail_returns_correct_value(): void
{
$entity = $this->buildEntity(['email' => 'john@example.com']);
$this->assertSame('john@example.com', $entity->getEmail());
$this->assertSame('john@example.com', $entity->getEmail()->value());
}

public function test_getAgeRange_returns_correct_enum(): void
Expand Down
35 changes: 35 additions & 0 deletions src/app/Packages/Domains/User/Tests/UserRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Packages\Domains\User\Subscription\SubscriptionTier;
use App\Packages\Domains\User\UserEntity;
use App\Packages\Domains\User\UserRepository;
use App\Packages\Domains\User\ValueObject\Email;
use App\Packages\Features\QueryUseCases\Dto\User\UserDto;
use Faker\Factory as FakerFactory;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -138,4 +139,38 @@ public function test_deleteUser_throws_exception_when_user_not_found(): void

$this->repository()->deleteUser(999999);
}

public function test_findByEmail_returns_user_entity_when_user_exists(): void
{
$user = $this->seedUser(['email' => 'john@example.com']);

$result = $this->repository()->findByEmail(new Email('john@example.com'));

$this->assertInstanceOf(UserEntity::class, $result);
$this->assertSame($user->email, $result->getEmail()->value());
$this->assertSame($user->first_name, $result->getFirstName());
$this->assertSame($user->last_name, $result->getLastName());
}

public function test_findByEmail_returns_null_when_user_does_not_exist(): void
{
$result = $this->repository()->findByEmail(new Email('notfound@example.com'));

$this->assertNull($result);
}

public function test_findByEmail_returns_correct_password_hash(): void
{
$plainPassword = 'secret123';
$this->seedUser([
'email' => 'hash@example.com',
'password' => bcrypt($plainPassword),
]);

$result = $this->repository()->findByEmail(new Email('hash@example.com'));

$this->assertNotNull($result->getPasswordHash());
$this->assertTrue(password_verify($plainPassword, $result->getPasswordHash()));
}

}
Loading
Loading