diff --git a/AGENTS.md b/AGENTS.md index 73e2226..3a4a6c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,9 @@ Before responding to the first user message in a session, you MUST: Skipping this protocol caused real bugs and rework in past sessions (work landed on master, TDD order was lost, formatter not run, banned constructs slipped in). Treat the protocol as non-negotiable. + +## Command execution + +When running commands that need the Nix devshell, set `login: false`. +The login shell resets `PATH` and can hide tools like `phpcs`, Cypress, +and the devshell Node version. diff --git a/backend/app/Auth/CreateSessionDto.php b/backend/app/Auth/CreateSessionDto.php index 2e5e2f7..d22b708 100644 --- a/backend/app/Auth/CreateSessionDto.php +++ b/backend/app/Auth/CreateSessionDto.php @@ -12,5 +12,6 @@ class CreateSessionDto public User $user, public DateTimeImmutable $createdAt, public DateTimeImmutable $expiresAt, - ) {} + ) { + } } diff --git a/backend/app/Auth/EloquentSessionRepository.php b/backend/app/Auth/EloquentSessionRepository.php index 01cd4be..92689b7 100644 --- a/backend/app/Auth/EloquentSessionRepository.php +++ b/backend/app/Auth/EloquentSessionRepository.php @@ -8,7 +8,9 @@ use DateTimeZone; class EloquentSessionRepository implements SessionRepository { - public function __construct(private UserRepository $userRepo) {} + public function __construct(private UserRepository $userRepo) + { + } public function create(CreateSessionDto $dto): Session { diff --git a/backend/app/Auth/Session.php b/backend/app/Auth/Session.php index b433114..b95905b 100644 --- a/backend/app/Auth/Session.php +++ b/backend/app/Auth/Session.php @@ -12,7 +12,8 @@ class Session private User $user, private DateTimeImmutable $createdAt, private DateTimeImmutable $expiresAt, - ) {} + ) { + } public function getToken(): string { diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php index 7e8c92c..e91d487 100644 --- a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php @@ -14,7 +14,8 @@ class AuthenticateUser public function __construct( private UserRepository $userRepo, private PasswordHasher $hasher, - ) {} + ) { + } /** * @throws BadRequestException diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php index aa8b1df..d7cf6dd 100644 --- a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php @@ -7,5 +7,6 @@ class AuthenticateUserRequest public function __construct( public ?string $email, public ?string $password, - ) {} + ) { + } } diff --git a/backend/app/Auth/UseCases/CreateSession/CreateSession.php b/backend/app/Auth/UseCases/CreateSession/CreateSession.php index db6403f..83014a6 100644 --- a/backend/app/Auth/UseCases/CreateSession/CreateSession.php +++ b/backend/app/Auth/UseCases/CreateSession/CreateSession.php @@ -17,7 +17,8 @@ class CreateSession private SessionRepository $sessionRepo, private TokenGenerator $tokenGenerator, private Clock $clock, - ) {} + ) { + } public function execute(User $user): Session { diff --git a/backend/app/Auth/UseCases/Logout/Logout.php b/backend/app/Auth/UseCases/Logout/Logout.php index 31b16de..793666b 100644 --- a/backend/app/Auth/UseCases/Logout/Logout.php +++ b/backend/app/Auth/UseCases/Logout/Logout.php @@ -8,7 +8,8 @@ class Logout { public function __construct( private SessionRepository $sessionRepo, - ) {} + ) { + } public function execute(string $token): void { diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php index 841b4c4..43c9964 100644 --- a/backend/app/Controllers/AuthController.php +++ b/backend/app/Controllers/AuthController.php @@ -20,7 +20,8 @@ class AuthController private AuthenticateUser $authenticateUser, private CreateSession $createSession, private Logout $logout, - ) {} + ) { + } public function login(Request $request): JsonResponse { @@ -33,11 +34,13 @@ class AuthController ); } catch (BadRequestException $exception) { return new JsonResponse( - ['error' => $exception->getMessage()], 400 + ['error' => $exception->getMessage()], + 400 ); } catch (UnauthorizedException $exception) { return new JsonResponse( - ['error' => $exception->getMessage()], 401 + ['error' => $exception->getMessage()], + 401 ); } @@ -71,7 +74,7 @@ class AuthController } /** - * @return array{id: int, email: string, firstname: string, lastname: string} + * @return array{id: int, email: string} */ private function buildUserPayload(User $user): array { diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php new file mode 100644 index 0000000..1a035c9 --- /dev/null +++ b/backend/app/Controllers/SetController.php @@ -0,0 +1,44 @@ +setRepository->getAll() as $set) { + $sets[] = $this->buildSetPayload($set); + } + + return new JsonResponse([ + 'sets' => $sets, + ], 200); + } + + /** + * @return array{ + * id: int, + * name: string, + * description: string, + * iconImageUrl: string + * } + */ + private function buildSetPayload(DomainSet $set): array + { + return [ + 'id' => $set->getId(), + 'name' => $set->getName(), + 'description' => $set->getDescription(), + 'iconImageUrl' => $set->getIconImageUrl(), + ]; + } +} diff --git a/backend/app/Element/CreateElementDto.php b/backend/app/Element/CreateElementDto.php index db77dd7..b815300 100644 --- a/backend/app/Element/CreateElementDto.php +++ b/backend/app/Element/CreateElementDto.php @@ -10,5 +10,6 @@ class CreateElementDto public Set $set, public string $title, public ?Element $parentElement, - ) {} + ) { + } } diff --git a/backend/app/Element/Element.php b/backend/app/Element/Element.php index 9ef75f6..143df26 100644 --- a/backend/app/Element/Element.php +++ b/backend/app/Element/Element.php @@ -11,7 +11,8 @@ class Element private string $title, private Set $set, private ?Element $parentElement, - ) {} + ) { + } public function getId(): int { diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index 56ec2e6..61e1433 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -8,7 +8,9 @@ use DomainException; class EloquentElementRepository implements ElementRepository { - public function __construct(private SetRepository $setRepo) {} + public function __construct(private SetRepository $setRepo) + { + } public function create(CreateElementDto $dto): Element { diff --git a/backend/app/Element/UseCases/CreateElement/CreateElement.php b/backend/app/Element/UseCases/CreateElement/CreateElement.php index 6656338..9b32cd7 100644 --- a/backend/app/Element/UseCases/CreateElement/CreateElement.php +++ b/backend/app/Element/UseCases/CreateElement/CreateElement.php @@ -15,7 +15,8 @@ class CreateElement public function __construct( private ElementRepository $elementRepo, private SetRepository $setRepo, - ) {} + ) { + } /** * @throws BadRequestException diff --git a/backend/app/Element/UseCases/CreateElement/CreateElementRequest.php b/backend/app/Element/UseCases/CreateElement/CreateElementRequest.php index b9f0dbd..eef7e7e 100644 --- a/backend/app/Element/UseCases/CreateElement/CreateElementRequest.php +++ b/backend/app/Element/UseCases/CreateElement/CreateElementRequest.php @@ -8,5 +8,6 @@ class CreateElementRequest public ?int $setId, public ?string $title, public ?int $parentElementId, - ) {} + ) { + } } diff --git a/backend/app/Exceptions/BadRequestException.php b/backend/app/Exceptions/BadRequestException.php index b900f47..1670d31 100644 --- a/backend/app/Exceptions/BadRequestException.php +++ b/backend/app/Exceptions/BadRequestException.php @@ -4,4 +4,6 @@ namespace App\Exceptions; use DomainException; -class BadRequestException extends DomainException {} +class BadRequestException extends DomainException +{ +} diff --git a/backend/app/Exceptions/UnauthorizedException.php b/backend/app/Exceptions/UnauthorizedException.php index f5d406e..84f3f71 100644 --- a/backend/app/Exceptions/UnauthorizedException.php +++ b/backend/app/Exceptions/UnauthorizedException.php @@ -4,4 +4,6 @@ namespace App\Exceptions; use DomainException; -class UnauthorizedException extends DomainException {} +class UnauthorizedException extends DomainException +{ +} diff --git a/backend/app/Http/Middleware/AuthMiddleware.php b/backend/app/Http/Middleware/AuthMiddleware.php index 8e21ece..0589cdb 100644 --- a/backend/app/Http/Middleware/AuthMiddleware.php +++ b/backend/app/Http/Middleware/AuthMiddleware.php @@ -16,7 +16,8 @@ class AuthMiddleware public function __construct( private SessionRepository $sessionRepo, private Clock $clock, - ) {} + ) { + } /** * @param Closure(Request): Response $next diff --git a/backend/app/Set/CreateSetDto.php b/backend/app/Set/CreateSetDto.php index 2999a88..934a410 100644 --- a/backend/app/Set/CreateSetDto.php +++ b/backend/app/Set/CreateSetDto.php @@ -6,5 +6,8 @@ class CreateSetDto { public function __construct( public string $name, - ) {} + public string $description, + public string $iconImageUrl, + ) { + } } diff --git a/backend/app/Set/EloquentSetRepository.php b/backend/app/Set/EloquentSetRepository.php index ef8976e..d7db606 100644 --- a/backend/app/Set/EloquentSetRepository.php +++ b/backend/app/Set/EloquentSetRepository.php @@ -8,6 +8,8 @@ class EloquentSetRepository implements SetRepository { $model = SetModel::create([ 'name' => $dto->name, + 'description' => $dto->description, + 'icon_image_url' => $dto->iconImageUrl, ]); return $this->toDomain($model); @@ -36,6 +38,8 @@ class EloquentSetRepository implements SetRepository return new Set( id: $model->id, name: $model->name, + description: $model->description, + iconImageUrl: $model->icon_image_url, ); } } diff --git a/backend/app/Set/Set.php b/backend/app/Set/Set.php index d045e70..bf44461 100644 --- a/backend/app/Set/Set.php +++ b/backend/app/Set/Set.php @@ -7,7 +7,10 @@ class Set public function __construct( private int $id, private string $name, - ) {} + private string $description, + private string $iconImageUrl, + ) { + } public function getId(): int { @@ -18,4 +21,14 @@ class Set { return $this->name; } + + public function getDescription(): string + { + return $this->description; + } + + public function getIconImageUrl(): string + { + return $this->iconImageUrl; + } } diff --git a/backend/app/Set/SetModel.php b/backend/app/Set/SetModel.php index c30e2c9..d80a6df 100644 --- a/backend/app/Set/SetModel.php +++ b/backend/app/Set/SetModel.php @@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $name + * @property string $description + * @property string $icon_image_url * * @method static Builder|SetModel newModelQuery() * @method static Builder|SetModel newQuery() @@ -23,5 +25,5 @@ class SetModel extends Model public $timestamps = false; - protected $fillable = ['name']; + protected $fillable = ['name', 'description', 'icon_image_url']; } diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php index a744918..00ed5cf 100644 --- a/backend/app/Shared/ValueObject/EmailAddress.php +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -18,15 +18,15 @@ final readonly class EmailAddress $trimmed = trim($email); if ($trimmed === '' || ! str_contains($trimmed, '@')) { - throw new InvalidArgumentException(self::ERROR_MESSAGE." $email"); + throw new InvalidArgumentException(self::ERROR_MESSAGE . " $email"); } [$local, $domain] = explode('@', $trimmed, 2); $this->domain = mb_strtolower($domain); - $normalized = $local.'@'.$this->domain; + $normalized = $local . '@' . $this->domain; if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) { - throw new InvalidArgumentException(self::ERROR_MESSAGE." $email"); + throw new InvalidArgumentException(self::ERROR_MESSAGE . " $email"); } $this->normalized = $normalized; diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index d10b373..848d9f7 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -9,5 +9,6 @@ class CreateUserDto public function __construct( public EmailAddress $email, public string $passwordHash, - ) {} + ) { + } } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index f69ec4a..8a1a2c9 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -25,7 +25,7 @@ class EloquentUserRepository implements UserRepository public function findByEmailDomain(string $domain): array { - $models = UserModel::where('email', 'like', '%@'.$domain)->get(); + $models = UserModel::where('email', 'like', '%@' . $domain)->get(); $users = []; foreach ($models as $model) { $users[] = $this->toDomain($model); diff --git a/backend/app/User/User.php b/backend/app/User/User.php index 3d8ed63..e73b5aa 100644 --- a/backend/app/User/User.php +++ b/backend/app/User/User.php @@ -10,7 +10,8 @@ class User private int $id, private EmailAddress $email, private string $passwordHash, - ) {} + ) { + } public function getId(): int { diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index 7d8c09d..6bde5dd 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -9,12 +9,6 @@ use Illuminate\Database\Eloquent\Model; * @property string $email * @property string $password_hash * - * @method static \Illuminate\Database\Eloquent\Builder|UserModel newModelQuery() - * @method static \Illuminate\Database\Eloquent\Builder|UserModel newQuery() - * @method static \Illuminate\Database\Eloquent\Builder|UserModel query() - * @method static \Illuminate\Database\Eloquent\Builder|UserModel whereEmail($value) - * @method static \Illuminate\Database\Eloquent\Builder|UserModel whereId($value) - * * @mixin \Eloquent */ class UserModel extends Model diff --git a/backend/config/auth.php b/backend/config/auth.php index d7568ff..3f8d685 100644 --- a/backend/config/auth.php +++ b/backend/config/auth.php @@ -95,7 +95,10 @@ return [ 'passwords' => [ 'users' => [ 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'table' => env( + 'AUTH_PASSWORD_RESET_TOKEN_TABLE', + 'password_reset_tokens' + ), 'expire' => 60, 'throttle' => 60, ], diff --git a/backend/config/cache.php b/backend/config/cache.php index c68acdf..8047361 100644 --- a/backend/config/cache.php +++ b/backend/config/cache.php @@ -112,7 +112,10 @@ return [ | */ - 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + 'prefix' => env( + 'CACHE_PREFIX', + Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-' + ), /* |-------------------------------------------------------------------------- diff --git a/backend/config/database.php b/backend/config/database.php index abbb88e..aed38f9 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -111,7 +111,10 @@ return [ 'prefix' => '', 'prefix_indexes' => true, // 'encrypt' => env('DB_ENCRYPT', 'yes'), - // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + // 'trust_server_certificate' => env( + // 'DB_TRUST_SERVER_CERTIFICATE', + // 'false' + // ), ], ], @@ -149,7 +152,11 @@ return [ 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'prefix' => env( + 'REDIS_PREFIX', + Str::slug((string) env('APP_NAME', 'laravel')) + . '-database-' + ), 'persistent' => env('REDIS_PERSISTENT', false), ], @@ -161,7 +168,10 @@ return [ 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0'), 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_algorithm' => env( + 'REDIS_BACKOFF_ALGORITHM', + 'decorrelated_jitter' + ), 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), ], @@ -174,7 +184,10 @@ return [ 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_CACHE_DB', '1'), 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_algorithm' => env( + 'REDIS_BACKOFF_ALGORITHM', + 'decorrelated_jitter' + ), 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), ], diff --git a/backend/config/filesystems.php b/backend/config/filesystems.php index 37d8fca..9d9f625 100644 --- a/backend/config/filesystems.php +++ b/backend/config/filesystems.php @@ -41,7 +41,10 @@ return [ 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'url' => rtrim( + env('APP_URL', 'http://localhost'), + '/' + ) . '/storage', 'visibility' => 'public', 'throw' => false, 'report' => false, @@ -55,7 +58,10 @@ return [ 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), - 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'use_path_style_endpoint' => env( + 'AWS_USE_PATH_STYLE_ENDPOINT', + false + ), 'throw' => false, 'report' => false, ], diff --git a/backend/config/logging.php b/backend/config/logging.php index b09cb25..fe06e47 100644 --- a/backend/config/logging.php +++ b/backend/config/logging.php @@ -89,7 +89,10 @@ return [ 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), - 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://' + . env('PAPERTRAIL_URL') + . ':' + . env('PAPERTRAIL_PORT'), ], 'processors' => [PsrLogMessageProcessor::class], ], diff --git a/backend/config/mail.php b/backend/config/mail.php index e32e88d..8d18020 100644 --- a/backend/config/mail.php +++ b/backend/config/mail.php @@ -46,7 +46,13 @@ return [ 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + 'local_domain' => env( + 'MAIL_EHLO_DOMAIN', + parse_url( + (string) env('APP_URL', 'http://localhost'), + PHP_URL_HOST + ) + ), ], 'ses' => [ diff --git a/backend/config/queue.php b/backend/config/queue.php index 79c2c0a..e0f7e11 100644 --- a/backend/config/queue.php +++ b/backend/config/queue.php @@ -57,7 +57,10 @@ return [ 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'prefix' => env( + 'SQS_PREFIX', + 'https://sqs.us-east-1.amazonaws.com/your-account-id' + ), 'queue' => env('SQS_QUEUE', 'default'), 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), diff --git a/backend/config/session.php b/backend/config/session.php index f574482..34a19a2 100644 --- a/backend/config/session.php +++ b/backend/config/session.php @@ -129,7 +129,7 @@ return [ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + Str::slug((string) env('APP_NAME', 'laravel')) . '-session' ), /* @@ -193,7 +193,7 @@ return [ | take place, and can be used to mitigate CSRF attacks. By default, we | will set this value to "lax" to permit secure cross-site requests. | - | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | See MDN Set-Cookie SameSite documentation. | | Supported: "lax", "strict", "none", null | diff --git a/backend/database/migrations/2026_05_24_000000_sets_table.php b/backend/database/migrations/2026_05_24_000000_sets_table.php index a39348b..f3ab093 100644 --- a/backend/database/migrations/2026_05_24_000000_sets_table.php +++ b/backend/database/migrations/2026_05_24_000000_sets_table.php @@ -11,6 +11,8 @@ return new class extends Migration Schema::create('sets', function (Blueprint $table) { $table->id(); $table->string('name'); + $table->text('description'); + $table->string('icon_image_url'); }); } diff --git a/backend/database/seeders/SetSeeder.php b/backend/database/seeders/SetSeeder.php index 2f2975c..b2ca3b5 100644 --- a/backend/database/seeders/SetSeeder.php +++ b/backend/database/seeders/SetSeeder.php @@ -15,6 +15,10 @@ class SetSeeder extends Seeder $set = $setRepository->create(new CreateSetDto( name: $title, + description: 'Baderech HaAvodah is a way of living - ' + . 'a structured path for inner and outer growth, ' + . 'spiritual refinement, and personal development.', + iconImageUrl: '/assets/baderech-haavodah-icon.png', )); } } diff --git a/backend/public/index.php b/backend/public/index.php index ee8f07e..f1aabdc 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -1,20 +1,23 @@ handleRequest(Request::capture()); diff --git a/backend/routes/api.php b/backend/routes/api.php index 9e1c55e..92cd42d 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ middleware(AuthMiddleware::class); +Route::get('/sets', [SetController::class, 'index']); diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php index f112836..7b14d8d 100644 --- a/backend/tests/Fakes/FakeClock.php +++ b/backend/tests/Fakes/FakeClock.php @@ -7,7 +7,9 @@ use DateTimeImmutable; class FakeClock implements Clock { - public function __construct(private DateTimeImmutable $currentTime) {} + public function __construct(private DateTimeImmutable $currentTime) + { + } public function now(): DateTimeImmutable { diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index 295e619..079ea93 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -19,6 +19,8 @@ class FakeSetRepository implements SetRepository $set = new DomainSet( id: $id, name: $dto->name, + description: $dto->description, + iconImageUrl: $dto->iconImageUrl, ); $this->setsById[$id] = $set; @@ -52,6 +54,8 @@ class FakeSetRepository implements SetRepository return new DomainSet( id: $set->getId(), name: $set->getName(), + description: $set->getDescription(), + iconImageUrl: $set->getIconImageUrl(), ); } } diff --git a/backend/tests/Fakes/FakeTokenGenerator.php b/backend/tests/Fakes/FakeTokenGenerator.php index e10bbcf..4788ab3 100644 --- a/backend/tests/Fakes/FakeTokenGenerator.php +++ b/backend/tests/Fakes/FakeTokenGenerator.php @@ -12,7 +12,9 @@ class FakeTokenGenerator implements TokenGenerator /** * @param string[] $tokens */ - public function __construct(private array $tokens) {} + public function __construct(private array $tokens) + { + } public function generate(): string { diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php new file mode 100644 index 0000000..f1ab5cc --- /dev/null +++ b/backend/tests/Feature/SetsEndpointTest.php @@ -0,0 +1,48 @@ +create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'Baderech HaAvodah is a way of living', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $dailyLearningSet = $setRepository->create(new CreateSetDto( + name: 'Daily Learning', + description: 'Daily learning for steady growth', + iconImageUrl: '/assets/daily-learning-icon.svg', + )); + + $response = $this->getJson('/api/sets'); + + $response->assertOk(); + $response->assertExactJson([ + 'sets' => [ + [ + 'id' => $baderechSet->getId(), + 'name' => $baderechSet->getName(), + 'description' => $baderechSet->getDescription(), + 'iconImageUrl' => $baderechSet->getIconImageUrl(), + ], + [ + 'id' => $dailyLearningSet->getId(), + 'name' => $dailyLearningSet->getName(), + 'description' => $dailyLearningSet->getDescription(), + 'iconImageUrl' => $dailyLearningSet->getIconImageUrl(), + ], + ], + ]); + } +} diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index 45aaf3d..33689a5 100644 --- a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -31,7 +31,7 @@ class AuthMiddlewareTest extends TestCase '2026-04-29T12:00:00', new DateTimeZone('UTC') ); - $this->sessionRepo = new FakeSessionRepository; + $this->sessionRepo = new FakeSessionRepository(); $this->clock = new FakeClock($this->now); $this->middleware = new AuthMiddleware( $this->sessionRepo, @@ -58,7 +58,7 @@ class AuthMiddlewareTest extends TestCase }; } - public function test_missing_cookie_returns_unauthorized_json(): void + public function testMissingCookieReturnsUnauthorizedJson(): void { $captured = null; $response = $this->middleware->handle( @@ -74,7 +74,7 @@ class AuthMiddlewareTest extends TestCase $this->assertNull($captured); } - public function test_unknown_token_returns_unauthorized(): void + public function testUnknownTokenReturnsUnauthorized(): void { $captured = null; $response = $this->middleware->handle( @@ -86,7 +86,7 @@ class AuthMiddlewareTest extends TestCase $this->assertNull($captured); } - public function test_expired_session_returns_unauthorized_and_is_deleted(): void + public function testExpiredSessionReturnsUnauthorizedAndIsDeleted(): void { $user = new User( id: 7, @@ -113,7 +113,7 @@ class AuthMiddlewareTest extends TestCase ); } - public function test_valid_session_attaches_user_and_calls_next(): void + public function testValidSessionAttachesUserAndCallsNext(): void { $user = new User( id: 7, diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php index b39dbff..df43f2c 100644 --- a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -25,7 +25,10 @@ class AuthenticateUserTest extends TestCase $this->userRepo = new FakeUserRepository(); $this->hasher = new FakeHasher(); - $this->authenticateUser = new AuthenticateUser($this->userRepo, $this->hasher); + $this->authenticateUser = new AuthenticateUser( + $this->userRepo, + $this->hasher + ); } public function testAuthenticatesValidUser(): void diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php index 034d7c5..d9fba39 100644 --- a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -44,8 +44,14 @@ class CreateSessionTest extends TestCase $this->assertSame('fake-token-123', $session->getToken()); $this->assertSame($user, $session->getUser()); $this->assertFalse($session->isExpired($this->clock->now())); - $this->assertSame('2026-05-18 12:00:00', $session->getCreatedAt()->format('Y-m-d H:i:s')); - $this->assertSame('2026-05-25 12:00:00', $session->getExpiresAt()->format('Y-m-d H:i:s')); + $this->assertSame( + '2026-05-18 12:00:00', + $session->getCreatedAt()->format('Y-m-d H:i:s') + ); + $this->assertSame( + '2026-05-25 12:00:00', + $session->getExpiresAt()->format('Y-m-d H:i:s') + ); $stored = $this->sessionRepo->findByToken($session->getToken()); $this->assertNotNull($stored); diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php index 5dda63f..84f6c28 100644 --- a/backend/tests/Unit/Auth/UseCases/LogoutTest.php +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -25,11 +25,11 @@ class LogoutTest extends TestCase '2026-04-29T12:00:00', new DateTimeZone('UTC') ); - $this->sessionRepo = new FakeSessionRepository; + $this->sessionRepo = new FakeSessionRepository(); $this->useCase = new Logout($this->sessionRepo); } - public function test_existing_token_session_is_removed(): void + public function testExistingTokenSessionIsRemoved(): void { $this->sessionRepo->create(new CreateSessionDto( token: 'token-abc', @@ -47,7 +47,7 @@ class LogoutTest extends TestCase $this->assertNull($this->sessionRepo->findByToken('token-abc')); } - public function test_unknown_token_does_not_throw(): void + public function testUnknownTokenDoesNotThrow(): void { $this->useCase->execute('unknown-token'); diff --git a/backend/tests/Unit/Controllers/AuthControllerTest.php b/backend/tests/Unit/Controllers/AuthControllerTest.php index 1855c59..29ce7de 100644 --- a/backend/tests/Unit/Controllers/AuthControllerTest.php +++ b/backend/tests/Unit/Controllers/AuthControllerTest.php @@ -73,7 +73,7 @@ class AuthControllerTest extends TestCase ); } - public function test_login_returns_200_and_sets_cookie_on_success(): void + public function testLoginReturns200AndSetsCookieOnSuccess(): void { $email = 'user@example.com'; $password = 'password'; @@ -104,21 +104,21 @@ class AuthControllerTest extends TestCase ); } - public function test_login_returns_400_when_email_missing(): void + public function testLoginReturns400WhenEmailMissing(): void { $request = new Request(['password' => 'correctpassword']); $response = $this->controller->login($request); $this->assertEquals(400, $response->getStatusCode()); } - public function test_login_returns_400_when_password_missing(): void + public function testLoginReturns400WhenPasswordMissing(): void { $request = new Request(['email' => 'user@example.com']); $response = $this->controller->login($request); $this->assertEquals(400, $response->getStatusCode()); } - public function test_login_returns_401_when_credentials_invalid(): void + public function testLoginReturns401WhenCredentialsInvalid(): void { $this->seedStartupUser('user@example.com', 'correctpassword'); @@ -130,7 +130,7 @@ class AuthControllerTest extends TestCase $this->assertEquals(401, $response->getStatusCode()); } - public function test_logout_returns_204_and_clears_cookie(): void + public function testLogoutReturns204AndClearsCookie(): void { $this->seedStartupUser('user@example.com', 'correctpassword'); $loginRequest = new Request([ @@ -139,7 +139,7 @@ class AuthControllerTest extends TestCase ]); $this->controller->login($loginRequest); - $logoutRequest = new Request; + $logoutRequest = new Request(); $logoutRequest->cookies->set( AuthMiddleware::COOKIE_NAME, 'session-token-1' @@ -160,7 +160,7 @@ class AuthControllerTest extends TestCase $this->assertSame('', $cookies[0]->getValue()); } - public function test_me_returns_200_with_user_when_authenticated(): void + public function testMeReturns200WithUserWhenAuthenticated(): void { $email = 'me@example.com'; $user = $this->userRepo->create( @@ -170,7 +170,7 @@ class AuthControllerTest extends TestCase ) ); - $request = new Request; + $request = new Request(); $request->attributes->set('user', $user); $response = $this->controller->me($request); diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php index 3e914d9..5d7ded2 100644 --- a/backend/tests/Unit/Element/ElementTest.php +++ b/backend/tests/Unit/Element/ElementTest.php @@ -10,7 +10,12 @@ class ElementTest extends TestCase { public function testCreatesElementWithNullableParent(): void { - $set = new DomainSet(1, 'Daily learning'); + $set = new DomainSet( + id: 1, + name: 'Daily learning', + description: 'Daily learning description', + iconImageUrl: '/assets/daily-learning-icon.svg', + ); $rootElement = new Element( id: 1, title: 'Root', diff --git a/backend/tests/Unit/Element/UseCases/CreateElementTest.php b/backend/tests/Unit/Element/UseCases/CreateElementTest.php index 2d01fc6..340f49e 100644 --- a/backend/tests/Unit/Element/UseCases/CreateElementTest.php +++ b/backend/tests/Unit/Element/UseCases/CreateElementTest.php @@ -7,6 +7,7 @@ use App\Element\UseCases\CreateElement\CreateElement; use App\Element\UseCases\CreateElement\CreateElementRequest; use App\Exceptions\BadRequestException; use App\Set\CreateSetDto; +use App\Set\Set as DomainSet; use DomainException; use Tests\Fakes\FakeElementRepository; use Tests\Fakes\FakeSetRepository; @@ -30,11 +31,18 @@ class CreateElementTest extends TestCase ); } + private function createSet(string $name): DomainSet + { + return $this->setRepo->create(new CreateSetDto( + name: $name, + description: "$name description", + iconImageUrl: '/assets/test-set-icon.svg', + )); + } + public function testCreatesRootElement(): void { - $set = $this->setRepo->create( - new CreateSetDto('Daily learning') - ); + $set = $this->createSet('Daily learning'); $element = $this->createElement->execute(new CreateElementRequest( setId: $set->getId(), @@ -50,9 +58,7 @@ class CreateElementTest extends TestCase public function testCreatesChildElement(): void { - $set = $this->setRepo->create( - new CreateSetDto('Daily learning') - ); + $set = $this->createSet('Daily learning'); $rootElement = $this->createElement->execute( new CreateElementRequest( setId: $set->getId(), @@ -114,9 +120,7 @@ class CreateElementTest extends TestCase public function testThrowsWhenParentElementDoesNotExist(): void { - $set = $this->setRepo->create( - new CreateSetDto('Daily learning') - ); + $set = $this->createSet('Daily learning'); $this->expectException(DomainException::class); $this->expectExceptionMessage( @@ -132,9 +136,7 @@ class CreateElementTest extends TestCase public function testThrowsWhenRootElementAlreadyExists(): void { - $set = $this->setRepo->create( - new CreateSetDto('Daily learning') - ); + $set = $this->createSet('Daily learning'); $this->createElement->execute(new CreateElementRequest( setId: $set->getId(), title: 'Root', @@ -155,12 +157,8 @@ class CreateElementTest extends TestCase public function testThrowsWhenParentBelongsToAnotherSet(): void { - $parentSet = $this->setRepo->create( - new CreateSetDto('Parent set') - ); - $childSet = $this->setRepo->create( - new CreateSetDto('Child set') - ); + $parentSet = $this->createSet('Parent set'); + $childSet = $this->createSet('Child set'); $parentElement = $this->createElement->execute( new CreateElementRequest( setId: $parentSet->getId(), diff --git a/backend/tests/Unit/Set/SetTest.php b/backend/tests/Unit/Set/SetTest.php index a98ac5f..b3159c2 100644 --- a/backend/tests/Unit/Set/SetTest.php +++ b/backend/tests/Unit/Set/SetTest.php @@ -9,9 +9,22 @@ class SetTest extends TestCase { public function testCreatesSetWithName(): void { - $set = new DomainSet(1, 'Daily learning'); + $set = new DomainSet( + id: 1, + name: 'Daily learning', + description: 'A structured path for daily growth', + iconImageUrl: '/assets/daily-learning-icon.svg', + ); $this->assertSame(1, $set->getId()); $this->assertSame('Daily learning', $set->getName()); + $this->assertSame( + 'A structured path for daily growth', + $set->getDescription() + ); + $this->assertSame( + '/assets/daily-learning-icon.svg', + $set->getIconImageUrl() + ); } } diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts new file mode 100644 index 0000000..1202ae9 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -0,0 +1,26 @@ +describe('media page sets', () => { + it('fetches and renders seeded set cards', () => { + cy.visit('/media') + + cy.get('header.site-header').within(() => { + cy.contains('Torah Media').should('be.visible') + cy.contains('About').should('be.visible') + cy.contains('Contact').should('be.visible') + cy.contains('Donate').should('be.visible') + }) + cy.contains('h1', 'Torah Media').should('be.visible') + cy.get('[data-cy="media-set-grid"]').should('be.visible') + cy.get('[data-cy="media-set-card"]', { timeout: 10000 }) + .should('have.length', 1) + .first() + .within(() => { + cy.get('img[data-cy="media-set-icon"]') + .should('be.visible') + .and('have.attr', 'src') + .and('include', '/assets/baderech-haavodah-icon.png') + cy.contains('h2', 'Baderech HaAvodah').should('be.visible') + cy.contains('a structured path for inner and outer growth') + .should('be.visible') + }) + }) +}) diff --git a/frontend/rabbi_gerzi/public/assets/baderech-haavodah-icon.png b/frontend/rabbi_gerzi/public/assets/baderech-haavodah-icon.png new file mode 100644 index 0000000..72603bb Binary files /dev/null and b/frontend/rabbi_gerzi/public/assets/baderech-haavodah-icon.png differ diff --git a/frontend/rabbi_gerzi/src/stores/mediaSets.ts b/frontend/rabbi_gerzi/src/stores/mediaSets.ts new file mode 100644 index 0000000..0ed97dd --- /dev/null +++ b/frontend/rabbi_gerzi/src/stores/mediaSets.ts @@ -0,0 +1,46 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export interface MediaSet { + id: number + name: string + description: string + iconImageUrl: string +} + +interface SetsResponse { + sets: MediaSet[] +} + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string + +export const useMediaSetsStore = defineStore('mediaSets', () => { + const sets = ref([]) + const isLoading = ref(false) + const error = ref(null) + + async function fetchSets(): Promise { + error.value = null + isLoading.value = true + + try { + const response = await fetch(`${API_BASE_URL}/api/sets`) + + if (!response.ok) { + sets.value = [] + error.value = 'Could not load media sets' + return + } + + const data: SetsResponse = await response.json() + sets.value = data.sets + } catch { + sets.value = [] + error.value = 'Network error - could not load media sets' + } finally { + isLoading.value = false + } + } + + return { sets, isLoading, error, fetchSets } +}) diff --git a/frontend/rabbi_gerzi/src/views/MediaPage.vue b/frontend/rabbi_gerzi/src/views/MediaPage.vue index b405d36..314d3d3 100644 --- a/frontend/rabbi_gerzi/src/views/MediaPage.vue +++ b/frontend/rabbi_gerzi/src/views/MediaPage.vue @@ -1,17 +1,56 @@