Merge branch 'feature/media-set-cards'

This commit is contained in:
Yisroel Baum 2026-05-25 21:28:20 +03:00
commit 070722e013
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
55 changed files with 508 additions and 122 deletions

View file

@ -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 Skipping this protocol caused real bugs and rework in past sessions
(work landed on master, TDD order was lost, formatter not run, banned (work landed on master, TDD order was lost, formatter not run, banned
constructs slipped in). Treat the protocol as non-negotiable. 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.

View file

@ -12,5 +12,6 @@ class CreateSessionDto
public User $user, public User $user,
public DateTimeImmutable $createdAt, public DateTimeImmutable $createdAt,
public DateTimeImmutable $expiresAt, public DateTimeImmutable $expiresAt,
) {} ) {
}
} }

View file

@ -8,7 +8,9 @@ use DateTimeZone;
class EloquentSessionRepository implements SessionRepository class EloquentSessionRepository implements SessionRepository
{ {
public function __construct(private UserRepository $userRepo) {} public function __construct(private UserRepository $userRepo)
{
}
public function create(CreateSessionDto $dto): Session public function create(CreateSessionDto $dto): Session
{ {

View file

@ -12,7 +12,8 @@ class Session
private User $user, private User $user,
private DateTimeImmutable $createdAt, private DateTimeImmutable $createdAt,
private DateTimeImmutable $expiresAt, private DateTimeImmutable $expiresAt,
) {} ) {
}
public function getToken(): string public function getToken(): string
{ {

View file

@ -14,7 +14,8 @@ class AuthenticateUser
public function __construct( public function __construct(
private UserRepository $userRepo, private UserRepository $userRepo,
private PasswordHasher $hasher, private PasswordHasher $hasher,
) {} ) {
}
/** /**
* @throws BadRequestException * @throws BadRequestException

View file

@ -7,5 +7,6 @@ class AuthenticateUserRequest
public function __construct( public function __construct(
public ?string $email, public ?string $email,
public ?string $password, public ?string $password,
) {} ) {
}
} }

View file

@ -17,7 +17,8 @@ class CreateSession
private SessionRepository $sessionRepo, private SessionRepository $sessionRepo,
private TokenGenerator $tokenGenerator, private TokenGenerator $tokenGenerator,
private Clock $clock, private Clock $clock,
) {} ) {
}
public function execute(User $user): Session public function execute(User $user): Session
{ {

View file

@ -8,7 +8,8 @@ class Logout
{ {
public function __construct( public function __construct(
private SessionRepository $sessionRepo, private SessionRepository $sessionRepo,
) {} ) {
}
public function execute(string $token): void public function execute(string $token): void
{ {

View file

@ -20,7 +20,8 @@ class AuthController
private AuthenticateUser $authenticateUser, private AuthenticateUser $authenticateUser,
private CreateSession $createSession, private CreateSession $createSession,
private Logout $logout, private Logout $logout,
) {} ) {
}
public function login(Request $request): JsonResponse public function login(Request $request): JsonResponse
{ {
@ -33,11 +34,13 @@ class AuthController
); );
} catch (BadRequestException $exception) { } catch (BadRequestException $exception) {
return new JsonResponse( return new JsonResponse(
['error' => $exception->getMessage()], 400 ['error' => $exception->getMessage()],
400
); );
} catch (UnauthorizedException $exception) { } catch (UnauthorizedException $exception) {
return new JsonResponse( 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 private function buildUserPayload(User $user): array
{ {

View file

@ -0,0 +1,44 @@
<?php
namespace App\Controllers;
use App\Set\Set as DomainSet;
use App\Set\SetRepository;
use Illuminate\Http\JsonResponse;
class SetController
{
public function __construct(private SetRepository $setRepository)
{
}
public function index(): JsonResponse
{
$sets = [];
foreach ($this->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(),
];
}
}

View file

@ -10,5 +10,6 @@ class CreateElementDto
public Set $set, public Set $set,
public string $title, public string $title,
public ?Element $parentElement, public ?Element $parentElement,
) {} ) {
}
} }

View file

@ -11,7 +11,8 @@ class Element
private string $title, private string $title,
private Set $set, private Set $set,
private ?Element $parentElement, private ?Element $parentElement,
) {} ) {
}
public function getId(): int public function getId(): int
{ {

View file

@ -8,7 +8,9 @@ use DomainException;
class EloquentElementRepository implements ElementRepository class EloquentElementRepository implements ElementRepository
{ {
public function __construct(private SetRepository $setRepo) {} public function __construct(private SetRepository $setRepo)
{
}
public function create(CreateElementDto $dto): Element public function create(CreateElementDto $dto): Element
{ {

View file

@ -15,7 +15,8 @@ class CreateElement
public function __construct( public function __construct(
private ElementRepository $elementRepo, private ElementRepository $elementRepo,
private SetRepository $setRepo, private SetRepository $setRepo,
) {} ) {
}
/** /**
* @throws BadRequestException * @throws BadRequestException

View file

@ -8,5 +8,6 @@ class CreateElementRequest
public ?int $setId, public ?int $setId,
public ?string $title, public ?string $title,
public ?int $parentElementId, public ?int $parentElementId,
) {} ) {
}
} }

View file

@ -4,4 +4,6 @@ namespace App\Exceptions;
use DomainException; use DomainException;
class BadRequestException extends DomainException {} class BadRequestException extends DomainException
{
}

View file

@ -4,4 +4,6 @@ namespace App\Exceptions;
use DomainException; use DomainException;
class UnauthorizedException extends DomainException {} class UnauthorizedException extends DomainException
{
}

View file

@ -16,7 +16,8 @@ class AuthMiddleware
public function __construct( public function __construct(
private SessionRepository $sessionRepo, private SessionRepository $sessionRepo,
private Clock $clock, private Clock $clock,
) {} ) {
}
/** /**
* @param Closure(Request): Response $next * @param Closure(Request): Response $next

View file

@ -6,5 +6,8 @@ class CreateSetDto
{ {
public function __construct( public function __construct(
public string $name, public string $name,
) {} public string $description,
public string $iconImageUrl,
) {
}
} }

View file

@ -8,6 +8,8 @@ class EloquentSetRepository implements SetRepository
{ {
$model = SetModel::create([ $model = SetModel::create([
'name' => $dto->name, 'name' => $dto->name,
'description' => $dto->description,
'icon_image_url' => $dto->iconImageUrl,
]); ]);
return $this->toDomain($model); return $this->toDomain($model);
@ -36,6 +38,8 @@ class EloquentSetRepository implements SetRepository
return new Set( return new Set(
id: $model->id, id: $model->id,
name: $model->name, name: $model->name,
description: $model->description,
iconImageUrl: $model->icon_image_url,
); );
} }
} }

View file

@ -7,7 +7,10 @@ class Set
public function __construct( public function __construct(
private int $id, private int $id,
private string $name, private string $name,
) {} private string $description,
private string $iconImageUrl,
) {
}
public function getId(): int public function getId(): int
{ {
@ -18,4 +21,14 @@ class Set
{ {
return $this->name; return $this->name;
} }
public function getDescription(): string
{
return $this->description;
}
public function getIconImageUrl(): string
{
return $this->iconImageUrl;
}
} }

View file

@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $description
* @property string $icon_image_url
* *
* @method static Builder<static>|SetModel newModelQuery() * @method static Builder<static>|SetModel newModelQuery()
* @method static Builder<static>|SetModel newQuery() * @method static Builder<static>|SetModel newQuery()
@ -23,5 +25,5 @@ class SetModel extends Model
public $timestamps = false; public $timestamps = false;
protected $fillable = ['name']; protected $fillable = ['name', 'description', 'icon_image_url'];
} }

View file

@ -18,15 +18,15 @@ final readonly class EmailAddress
$trimmed = trim($email); $trimmed = trim($email);
if ($trimmed === '' || ! str_contains($trimmed, '@')) { if ($trimmed === '' || ! str_contains($trimmed, '@')) {
throw new InvalidArgumentException(self::ERROR_MESSAGE." $email"); throw new InvalidArgumentException(self::ERROR_MESSAGE . " $email");
} }
[$local, $domain] = explode('@', $trimmed, 2); [$local, $domain] = explode('@', $trimmed, 2);
$this->domain = mb_strtolower($domain); $this->domain = mb_strtolower($domain);
$normalized = $local.'@'.$this->domain; $normalized = $local . '@' . $this->domain;
if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) { 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; $this->normalized = $normalized;

View file

@ -9,5 +9,6 @@ class CreateUserDto
public function __construct( public function __construct(
public EmailAddress $email, public EmailAddress $email,
public string $passwordHash, public string $passwordHash,
) {} ) {
}
} }

View file

@ -25,7 +25,7 @@ class EloquentUserRepository implements UserRepository
public function findByEmailDomain(string $domain): array public function findByEmailDomain(string $domain): array
{ {
$models = UserModel::where('email', 'like', '%@'.$domain)->get(); $models = UserModel::where('email', 'like', '%@' . $domain)->get();
$users = []; $users = [];
foreach ($models as $model) { foreach ($models as $model) {
$users[] = $this->toDomain($model); $users[] = $this->toDomain($model);

View file

@ -10,7 +10,8 @@ class User
private int $id, private int $id,
private EmailAddress $email, private EmailAddress $email,
private string $passwordHash, private string $passwordHash,
) {} ) {
}
public function getId(): int public function getId(): int
{ {

View file

@ -9,12 +9,6 @@ use Illuminate\Database\Eloquent\Model;
* @property string $email * @property string $email
* @property string $password_hash * @property string $password_hash
* *
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|UserModel whereId($value)
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class UserModel extends Model class UserModel extends Model

View file

@ -95,7 +95,10 @@ return [
'passwords' => [ 'passwords' => [
'users' => [ 'users' => [
'provider' => '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, 'expire' => 60,
'throttle' => 60, 'throttle' => 60,
], ],

View file

@ -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-'
),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -111,7 +111,10 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'), // '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' => [ 'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'), '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), 'persistent' => env('REDIS_PERSISTENT', false),
], ],
@ -161,7 +168,10 @@ return [
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'), 'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3), '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_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
@ -174,7 +184,10 @@ return [
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'), 'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3), '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_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],

View file

@ -41,7 +41,10 @@ return [
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), 'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', 'url' => rtrim(
env('APP_URL', 'http://localhost'),
'/'
) . '/storage',
'visibility' => 'public', 'visibility' => 'public',
'throw' => false, 'throw' => false,
'report' => false, 'report' => false,
@ -55,7 +58,10 @@ return [
'bucket' => env('AWS_BUCKET'), 'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'), 'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'), '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, 'throw' => false,
'report' => false, 'report' => false,
], ],

View file

@ -89,7 +89,10 @@ return [
'handler_with' => [ 'handler_with' => [
'host' => env('PAPERTRAIL_URL'), 'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'), 'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), 'connectionString' => 'tls://'
. env('PAPERTRAIL_URL')
. ':'
. env('PAPERTRAIL_PORT'),
], ],
'processors' => [PsrLogMessageProcessor::class], 'processors' => [PsrLogMessageProcessor::class],
], ],

View file

@ -46,7 +46,13 @@ return [
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'timeout' => null, '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' => [ 'ses' => [

View file

@ -57,7 +57,10 @@ return [
'driver' => 'sqs', 'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'), 'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'), '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'), 'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'), 'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),

View file

@ -129,7 +129,7 @@ return [
'cookie' => env( 'cookie' => env(
'SESSION_COOKIE', '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 | 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. | 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 | Supported: "lax", "strict", "none", null
| |

View file

@ -11,6 +11,8 @@ return new class extends Migration
Schema::create('sets', function (Blueprint $table) { Schema::create('sets', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('name'); $table->string('name');
$table->text('description');
$table->string('icon_image_url');
}); });
} }

View file

@ -15,6 +15,10 @@ class SetSeeder extends Seeder
$set = $setRepository->create(new CreateSetDto( $set = $setRepository->create(new CreateSetDto(
name: $title, 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',
)); ));
} }
} }

View file

@ -1,20 +1,23 @@
<?php <?php
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Http\Request; use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true)); define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode... // Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) { $maintenance = __DIR__ . '/../storage/framework/maintenance.php';
if (file_exists($maintenance)) {
require $maintenance; require $maintenance;
} }
// Register the Composer autoloader... // Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
// Bootstrap Laravel and handle the request... // Bootstrap Laravel and handle the request...
/** @var Application $app */ /** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php'; $app = require_once __DIR__ . '/../bootstrap/app.php';
$app->handleRequest(Request::capture()); $app->handleRequest(Request::capture());

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Controllers\AuthController; use App\Controllers\AuthController;
use App\Controllers\SetController;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -8,3 +9,4 @@ Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']) Route::get('/me', [AuthController::class, 'me'])
->middleware(AuthMiddleware::class); ->middleware(AuthMiddleware::class);
Route::get('/sets', [SetController::class, 'index']);

View file

@ -7,7 +7,9 @@ use DateTimeImmutable;
class FakeClock implements Clock class FakeClock implements Clock
{ {
public function __construct(private DateTimeImmutable $currentTime) {} public function __construct(private DateTimeImmutable $currentTime)
{
}
public function now(): DateTimeImmutable public function now(): DateTimeImmutable
{ {

View file

@ -19,6 +19,8 @@ class FakeSetRepository implements SetRepository
$set = new DomainSet( $set = new DomainSet(
id: $id, id: $id,
name: $dto->name, name: $dto->name,
description: $dto->description,
iconImageUrl: $dto->iconImageUrl,
); );
$this->setsById[$id] = $set; $this->setsById[$id] = $set;
@ -52,6 +54,8 @@ class FakeSetRepository implements SetRepository
return new DomainSet( return new DomainSet(
id: $set->getId(), id: $set->getId(),
name: $set->getName(), name: $set->getName(),
description: $set->getDescription(),
iconImageUrl: $set->getIconImageUrl(),
); );
} }
} }

View file

@ -12,7 +12,9 @@ class FakeTokenGenerator implements TokenGenerator
/** /**
* @param string[] $tokens * @param string[] $tokens
*/ */
public function __construct(private array $tokens) {} public function __construct(private array $tokens)
{
}
public function generate(): string public function generate(): string
{ {

View file

@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Set\CreateSetDto;
use App\Set\SetRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SetsEndpointTest extends TestCase
{
use RefreshDatabase;
public function testReturnsAllSets(): void
{
$setRepository = app(SetRepository::class);
$baderechSet = $setRepository->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(),
],
],
]);
}
}

View file

@ -31,7 +31,7 @@ class AuthMiddlewareTest extends TestCase
'2026-04-29T12:00:00', '2026-04-29T12:00:00',
new DateTimeZone('UTC') new DateTimeZone('UTC')
); );
$this->sessionRepo = new FakeSessionRepository; $this->sessionRepo = new FakeSessionRepository();
$this->clock = new FakeClock($this->now); $this->clock = new FakeClock($this->now);
$this->middleware = new AuthMiddleware( $this->middleware = new AuthMiddleware(
$this->sessionRepo, $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; $captured = null;
$response = $this->middleware->handle( $response = $this->middleware->handle(
@ -74,7 +74,7 @@ class AuthMiddlewareTest extends TestCase
$this->assertNull($captured); $this->assertNull($captured);
} }
public function test_unknown_token_returns_unauthorized(): void public function testUnknownTokenReturnsUnauthorized(): void
{ {
$captured = null; $captured = null;
$response = $this->middleware->handle( $response = $this->middleware->handle(
@ -86,7 +86,7 @@ class AuthMiddlewareTest extends TestCase
$this->assertNull($captured); $this->assertNull($captured);
} }
public function test_expired_session_returns_unauthorized_and_is_deleted(): void public function testExpiredSessionReturnsUnauthorizedAndIsDeleted(): void
{ {
$user = new User( $user = new User(
id: 7, 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( $user = new User(
id: 7, id: 7,

View file

@ -25,7 +25,10 @@ class AuthenticateUserTest extends TestCase
$this->userRepo = new FakeUserRepository(); $this->userRepo = new FakeUserRepository();
$this->hasher = new FakeHasher(); $this->hasher = new FakeHasher();
$this->authenticateUser = new AuthenticateUser($this->userRepo, $this->hasher); $this->authenticateUser = new AuthenticateUser(
$this->userRepo,
$this->hasher
);
} }
public function testAuthenticatesValidUser(): void public function testAuthenticatesValidUser(): void

View file

@ -44,8 +44,14 @@ class CreateSessionTest extends TestCase
$this->assertSame('fake-token-123', $session->getToken()); $this->assertSame('fake-token-123', $session->getToken());
$this->assertSame($user, $session->getUser()); $this->assertSame($user, $session->getUser());
$this->assertFalse($session->isExpired($this->clock->now())); $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(
$this->assertSame('2026-05-25 12:00:00', $session->getExpiresAt()->format('Y-m-d H:i:s')); '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()); $stored = $this->sessionRepo->findByToken($session->getToken());
$this->assertNotNull($stored); $this->assertNotNull($stored);

View file

@ -25,11 +25,11 @@ class LogoutTest extends TestCase
'2026-04-29T12:00:00', '2026-04-29T12:00:00',
new DateTimeZone('UTC') new DateTimeZone('UTC')
); );
$this->sessionRepo = new FakeSessionRepository; $this->sessionRepo = new FakeSessionRepository();
$this->useCase = new Logout($this->sessionRepo); $this->useCase = new Logout($this->sessionRepo);
} }
public function test_existing_token_session_is_removed(): void public function testExistingTokenSessionIsRemoved(): void
{ {
$this->sessionRepo->create(new CreateSessionDto( $this->sessionRepo->create(new CreateSessionDto(
token: 'token-abc', token: 'token-abc',
@ -47,7 +47,7 @@ class LogoutTest extends TestCase
$this->assertNull($this->sessionRepo->findByToken('token-abc')); $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'); $this->useCase->execute('unknown-token');

View file

@ -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'; $email = 'user@example.com';
$password = 'password'; $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']); $request = new Request(['password' => 'correctpassword']);
$response = $this->controller->login($request); $response = $this->controller->login($request);
$this->assertEquals(400, $response->getStatusCode()); $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']); $request = new Request(['email' => 'user@example.com']);
$response = $this->controller->login($request); $response = $this->controller->login($request);
$this->assertEquals(400, $response->getStatusCode()); $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'); $this->seedStartupUser('user@example.com', 'correctpassword');
@ -130,7 +130,7 @@ class AuthControllerTest extends TestCase
$this->assertEquals(401, $response->getStatusCode()); $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'); $this->seedStartupUser('user@example.com', 'correctpassword');
$loginRequest = new Request([ $loginRequest = new Request([
@ -139,7 +139,7 @@ class AuthControllerTest extends TestCase
]); ]);
$this->controller->login($loginRequest); $this->controller->login($loginRequest);
$logoutRequest = new Request; $logoutRequest = new Request();
$logoutRequest->cookies->set( $logoutRequest->cookies->set(
AuthMiddleware::COOKIE_NAME, AuthMiddleware::COOKIE_NAME,
'session-token-1' 'session-token-1'
@ -160,7 +160,7 @@ class AuthControllerTest extends TestCase
$this->assertSame('', $cookies[0]->getValue()); $this->assertSame('', $cookies[0]->getValue());
} }
public function test_me_returns_200_with_user_when_authenticated(): void public function testMeReturns200WithUserWhenAuthenticated(): void
{ {
$email = 'me@example.com'; $email = 'me@example.com';
$user = $this->userRepo->create( $user = $this->userRepo->create(
@ -170,7 +170,7 @@ class AuthControllerTest extends TestCase
) )
); );
$request = new Request; $request = new Request();
$request->attributes->set('user', $user); $request->attributes->set('user', $user);
$response = $this->controller->me($request); $response = $this->controller->me($request);

View file

@ -10,7 +10,12 @@ class ElementTest extends TestCase
{ {
public function testCreatesElementWithNullableParent(): void 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( $rootElement = new Element(
id: 1, id: 1,
title: 'Root', title: 'Root',

View file

@ -7,6 +7,7 @@ use App\Element\UseCases\CreateElement\CreateElement;
use App\Element\UseCases\CreateElement\CreateElementRequest; use App\Element\UseCases\CreateElement\CreateElementRequest;
use App\Exceptions\BadRequestException; use App\Exceptions\BadRequestException;
use App\Set\CreateSetDto; use App\Set\CreateSetDto;
use App\Set\Set as DomainSet;
use DomainException; use DomainException;
use Tests\Fakes\FakeElementRepository; use Tests\Fakes\FakeElementRepository;
use Tests\Fakes\FakeSetRepository; 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 public function testCreatesRootElement(): void
{ {
$set = $this->setRepo->create( $set = $this->createSet('Daily learning');
new CreateSetDto('Daily learning')
);
$element = $this->createElement->execute(new CreateElementRequest( $element = $this->createElement->execute(new CreateElementRequest(
setId: $set->getId(), setId: $set->getId(),
@ -50,9 +58,7 @@ class CreateElementTest extends TestCase
public function testCreatesChildElement(): void public function testCreatesChildElement(): void
{ {
$set = $this->setRepo->create( $set = $this->createSet('Daily learning');
new CreateSetDto('Daily learning')
);
$rootElement = $this->createElement->execute( $rootElement = $this->createElement->execute(
new CreateElementRequest( new CreateElementRequest(
setId: $set->getId(), setId: $set->getId(),
@ -114,9 +120,7 @@ class CreateElementTest extends TestCase
public function testThrowsWhenParentElementDoesNotExist(): void public function testThrowsWhenParentElementDoesNotExist(): void
{ {
$set = $this->setRepo->create( $set = $this->createSet('Daily learning');
new CreateSetDto('Daily learning')
);
$this->expectException(DomainException::class); $this->expectException(DomainException::class);
$this->expectExceptionMessage( $this->expectExceptionMessage(
@ -132,9 +136,7 @@ class CreateElementTest extends TestCase
public function testThrowsWhenRootElementAlreadyExists(): void public function testThrowsWhenRootElementAlreadyExists(): void
{ {
$set = $this->setRepo->create( $set = $this->createSet('Daily learning');
new CreateSetDto('Daily learning')
);
$this->createElement->execute(new CreateElementRequest( $this->createElement->execute(new CreateElementRequest(
setId: $set->getId(), setId: $set->getId(),
title: 'Root', title: 'Root',
@ -155,12 +157,8 @@ class CreateElementTest extends TestCase
public function testThrowsWhenParentBelongsToAnotherSet(): void public function testThrowsWhenParentBelongsToAnotherSet(): void
{ {
$parentSet = $this->setRepo->create( $parentSet = $this->createSet('Parent set');
new CreateSetDto('Parent set') $childSet = $this->createSet('Child set');
);
$childSet = $this->setRepo->create(
new CreateSetDto('Child set')
);
$parentElement = $this->createElement->execute( $parentElement = $this->createElement->execute(
new CreateElementRequest( new CreateElementRequest(
setId: $parentSet->getId(), setId: $parentSet->getId(),

View file

@ -9,9 +9,22 @@ class SetTest extends TestCase
{ {
public function testCreatesSetWithName(): void 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(1, $set->getId());
$this->assertSame('Daily learning', $set->getName()); $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()
);
} }
} }

View file

@ -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')
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -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<MediaSet[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchSets(): Promise<void> {
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 }
})

View file

@ -1,17 +1,56 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import SiteHeader from '@/components/SiteHeader.vue' import SiteHeader from '@/components/SiteHeader.vue'
import { useMediaSetsStore } from '@/stores/mediaSets'
const mediaSetsStore = useMediaSetsStore()
const { sets, isLoading, error } = storeToRefs(mediaSetsStore)
onMounted(() => {
void mediaSetsStore.fetchSets()
})
</script> </script>
<template> <template>
<div class="media-page"> <div class="media-page">
<SiteHeader /> <SiteHeader />
<main class="media-page__main" data-cy="media-page"> <main class="media-page__main" data-cy="media-page">
<section class="media-page__hero"> <header class="media-page__header">
<p class="media-page__eyebrow">Rabbi Yehoshua Gerzi</p>
<h1 class="media-page__heading">Torah Media</h1> <h1 class="media-page__heading">Torah Media</h1>
<p class="media-page__intro"> </header>
Explore Rabbi Gerzi's Torah videos, audio teachings, and written resources.
<section class="media-page__sets" aria-label="Featured sets">
<p v-if="isLoading" class="media-page__status">Loading media sets...</p>
<p
v-else-if="error"
class="media-page__status media-page__status--error"
data-cy="media-sets-error"
>
{{ error }}
</p> </p>
<p v-else-if="sets.length === 0" class="media-page__status">
No media sets are available yet.
</p>
<div v-else class="media-page__grid" data-cy="media-set-grid">
<article
v-for="mediaSet in sets"
:key="mediaSet.id"
class="media-page__card"
data-cy="media-set-card"
>
<img
:src="mediaSet.iconImageUrl"
:alt="`${mediaSet.name} icon`"
class="media-page__card-icon"
data-cy="media-set-icon"
/>
<h2 class="media-page__card-title">{{ mediaSet.name }}</h2>
<p class="media-page__card-description">
{{ mediaSet.description }}
</p>
</article>
</div>
</section> </section>
</main> </main>
</div> </div>
@ -20,48 +59,88 @@ import SiteHeader from '@/components/SiteHeader.vue'
<style scoped> <style scoped>
.media-page { .media-page {
min-height: 100vh; min-height: 100vh;
background: var(--color-cream); background: var(--color-white);
} }
.media-page__main { .media-page__main {
padding: 5rem 2rem; min-height: 100vh;
padding: 5.1rem 4.15rem 5rem;
} }
.media-page__hero { .media-page__header {
max-width: 760px;
margin: 0 auto;
padding: 4rem 3rem;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: 14px;
text-align: center; text-align: center;
} }
.media-page__eyebrow {
margin: 0 0 1rem;
color: var(--color-olive);
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.media-page__heading { .media-page__heading {
margin: 0; margin: 0;
color: var(--color-slate); color: #2c2c2c;
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: clamp(2.25rem, 5vw, 4rem); font-size: clamp(2.6rem, 4.8vw, 3.2rem);
font-weight: 400; font-weight: 400;
line-height: 1.1; line-height: 1.1;
} }
.media-page__intro { .media-page__sets {
max-width: 34rem; margin-top: 5rem;
margin: 1.5rem auto 0; }
.media-page__status {
max-width: 48rem;
margin: 0 auto;
padding: 1rem 1.25rem;
color: var(--color-text-muted); color: var(--color-text-muted);
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.95rem;
}
.media-page__status--error {
color: #7c2d2d;
border-color: #e5b8b8;
}
.media-page__grid {
display: grid;
max-width: 1745px;
margin: 0 auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2.7rem 3.75rem;
}
.media-page__card {
min-height: 448px;
padding: 3.1rem 3rem 2.7rem;
background: var(--color-white);
border: 1px solid #e5cf9f;
border-radius: 17px;
text-align: center;
}
.media-page__card-icon {
display: block;
width: 140px;
height: 140px;
margin: 0 auto 2.65rem;
object-fit: contain;
}
.media-page__card-title {
margin: 0;
color: #333333;
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 1.1rem; font-size: clamp(2rem, 3.7vw, 2.4rem);
line-height: 1.7; font-weight: 400;
line-height: 1.2;
}
.media-page__card-description {
max-width: 35rem;
margin: 2.15rem auto 0;
color: #333333;
font-family: var(--font-serif);
font-size: clamp(1.25rem, 2.3vw, 1.45rem);
line-height: 1.38;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@ -69,8 +148,24 @@ import SiteHeader from '@/components/SiteHeader.vue'
padding: 3rem 1rem; padding: 3rem 1rem;
} }
.media-page__hero { .media-page__sets {
padding: 3rem 1.5rem; margin-top: 3rem;
}
.media-page__grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.media-page__card {
min-height: 390px;
padding: 2.5rem 1.5rem;
}
.media-page__card-icon {
width: 118px;
height: 118px;
margin-bottom: 2rem;
} }
} }
</style> </style>