Compare commits

..

18 commits

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
(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.

View file

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

View file

@ -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
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
{

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 string $title,
public ?Element $parentElement,
) {}
) {
}
}

View file

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

View file

@ -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
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -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<static>|SetModel newModelQuery()
* @method static Builder<static>|SetModel newQuery()
@ -23,5 +25,5 @@ class SetModel extends Model
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);
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;

View file

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

View file

@ -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);

View file

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

View file

@ -9,12 +9,6 @@ use Illuminate\Database\Eloquent\Model;
* @property string $email
* @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
*/
class UserModel extends Model

View file

@ -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,
],

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_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),
],

View file

@ -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,
],

View file

@ -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],
],

View file

@ -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' => [

View file

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

View file

@ -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
|

View file

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

View file

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

View file

@ -1,20 +1,23 @@
<?php
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// 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;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
require __DIR__ . '/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app = require_once __DIR__ . '/../bootstrap/app.php';
$app->handleRequest(Request::capture());

View file

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

View file

@ -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
{

View file

@ -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(),
);
}
}

View file

@ -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
{

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',
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,

View file

@ -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

View file

@ -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);

View file

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

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';
$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);

View file

@ -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',

View file

@ -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(),

View file

@ -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()
);
}
}

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">
import { storeToRefs } from 'pinia'
import { onMounted } from '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>
<template>
<div class="media-page">
<SiteHeader />
<main class="media-page__main" data-cy="media-page">
<section class="media-page__hero">
<p class="media-page__eyebrow">Rabbi Yehoshua Gerzi</p>
<header class="media-page__header">
<h1 class="media-page__heading">Torah Media</h1>
<p class="media-page__intro">
Explore Rabbi Gerzi's Torah videos, audio teachings, and written resources.
</header>
<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 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>
</main>
</div>
@ -20,48 +59,88 @@ import SiteHeader from '@/components/SiteHeader.vue'
<style scoped>
.media-page {
min-height: 100vh;
background: var(--color-cream);
background: var(--color-white);
}
.media-page__main {
padding: 5rem 2rem;
min-height: 100vh;
padding: 5.1rem 4.15rem 5rem;
}
.media-page__hero {
max-width: 760px;
margin: 0 auto;
padding: 4rem 3rem;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: 14px;
.media-page__header {
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 {
margin: 0;
color: var(--color-slate);
color: #2c2c2c;
font-family: var(--font-serif);
font-size: clamp(2.25rem, 5vw, 4rem);
font-size: clamp(2.6rem, 4.8vw, 3.2rem);
font-weight: 400;
line-height: 1.1;
}
.media-page__intro {
max-width: 34rem;
margin: 1.5rem auto 0;
.media-page__sets {
margin-top: 5rem;
}
.media-page__status {
max-width: 48rem;
margin: 0 auto;
padding: 1rem 1.25rem;
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-size: 1.1rem;
line-height: 1.7;
font-size: clamp(2rem, 3.7vw, 2.4rem);
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) {
@ -69,8 +148,24 @@ import SiteHeader from '@/components/SiteHeader.vue'
padding: 3rem 1rem;
}
.media-page__hero {
padding: 3rem 1.5rem;
.media-page__sets {
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>