diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 5e4cdf1c6e..0fe95494e4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,9 +5,11 @@ on: branches: [main, develop] paths: - 'backend/**' + - '.github/workflows/unit-tests.yml' pull_request: paths: - 'backend/**' + - '.github/workflows/unit-tests.yml' jobs: run-tests: @@ -17,6 +19,31 @@ jobs: matrix: php-versions: ['8.2', '8.3', '8.4'] + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: hievents_test + POSTGRES_USER: hievents + POSTGRES_PASSWORD: hievents + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U hievents -d hievents_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + # Job-level env. .env.testing supplies the rest, but the DB host on a CI + # runner is 127.0.0.1 (service container exposes its port on the runner), + # not the docker network alias used locally — override here. + env: + DB_HOST: 127.0.0.1 + DB_PORT: 5432 + DB_DATABASE: hievents_test + DB_USERNAME: hievents + DB_PASSWORD: hievents + steps: - name: Checkout code uses: actions/checkout@v3 @@ -25,7 +52,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, tokenizer + extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, pdo_pgsql, pgsql, tokenizer ini-values: post_max_size=256M, upload_max_filesize=256M coverage: none @@ -47,5 +74,22 @@ jobs: - name: Install dependencies run: cd backend && composer install --prefer-dist --no-progress --no-interaction + - name: Stage .env for testing + # Laravel auto-loads .env.testing when APP_ENV=testing, but artisan + # commands run outside that flow read .env directly. Copy .env.testing + # to .env so both paths see the same config. + run: cp backend/.env.testing backend/.env + + - name: Wait for Postgres + run: | + for i in {1..30}; do + if pg_isready -h 127.0.0.1 -p 5432 -U hievents -d hievents_test; then + exit 0 + fi + sleep 1 + done + echo "Postgres did not become ready in time" >&2 + exit 1 + - name: Run PHPUnit Tests run: cd backend && ./vendor/bin/phpunit tests/Unit --no-coverage diff --git a/CLAUDE.md b/CLAUDE.md index f283207d7f..0b0c572958 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,8 @@ cd docker/development - **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead - Unit tests extend Laravel's TestCase, not PHPUnit's TestCase - Use Mockery for mocking +- Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml`. The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'` +- Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass. ### Frontend diff --git a/backend/.env.testing b/backend/.env.testing new file mode 100644 index 0000000000..e559763ef8 --- /dev/null +++ b/backend/.env.testing @@ -0,0 +1,43 @@ +# Auto-loaded by Laravel when APP_ENV=testing (i.e. whenever PHPUnit runs). +# Safe to commit — contains only test-only credentials and fixed test secrets. +# Real secrets must NEVER be added here. + +APP_NAME=Hi.Events +APP_ENV=testing +# Static, test-only AES-256 key. Do not reuse outside tests. +APP_KEY=base64:rasMRv+Gm0oDMcBq+j9MvRgR3a6JYPTZjpRD4rGG2wA= +APP_DEBUG=true +APP_URL=http://localhost +APP_FRONTEND_URL=http://localhost +APP_LOG_QUERIES=false +APP_SAAS_MODE_ENABLED=false + +LOG_CHANNEL=stderr +LOG_LEVEL=debug + +# Database — must end in _test (BaseRepositoryTest enforces this). +# CI exports overrides via the workflow; locally these defaults match the +# docker-compose pgsql service. +DB_CONNECTION=pgsql +DB_HOST=pgsql +DB_PORT=5432 +DB_DATABASE=hievents_test +DB_USERNAME=username +DB_PASSWORD=password + +# Stateless drivers — keep tests hermetic, no external dependencies. +BROADCAST_DRIVER=log +CACHE_DRIVER=array +FILESYSTEM_PUBLIC_DISK=local +FILESYSTEM_PRIVATE_DISK=local +QUEUE_CONNECTION=sync +SESSION_DRIVER=array +SESSION_LIFETIME=120 +MAIL_MAILER=array + +# Fixed test JWT secret — do not reuse outside tests. +JWT_SECRET=test-jwt-secret-not-for-production-use-only-in-tests-aaaaaaaaaa +JWT_ALGO=HS256 + +BCRYPT_ROUNDS=4 +TELESCOPE_ENABLED=false diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index f00f8717c9..191d18ef73 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Carbon\Carbon; +use Closure; use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\Http\DTO\QueryParamsDTO; @@ -18,6 +19,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Foundation\Application; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -26,6 +28,7 @@ /** * @template T of DomainObjectInterface + * * @implements RepositoryInterface */ abstract class BaseRepository implements RepositoryInterface @@ -50,20 +53,18 @@ public function __construct(Application $application, DatabaseManager $db) /** * Returns a FQCL of the model - * - * @return string */ abstract protected function getModel(): string; /** - * @param class-string $domainObjectClass + * @param class-string $domainObjectClass */ protected function validateSortColumn(?string $sortBy, string $domainObjectClass): string { $allowedColumns = array_keys($domainObjectClass::getAllowedSorts()->toArray()); $default = $domainObjectClass::getDefaultSort(); - if ($sortBy === null || !in_array($sortBy, $allowedColumns, true)) { + if ($sortBy === null || ! in_array($sortBy, $allowedColumns, true)) { return $default; } @@ -86,61 +87,63 @@ public function setMaxPerPage(int $maxPerPage): static public function all(array $columns = self::DEFAULT_COLUMNS): Collection { - $models = $this->model->all($columns); - $this->resetModel(); - - return $this->handleResults($models); + return $this->runQuery( + fn () => $this->handleResults($this->model->all($columns)) + ); } public function paginate( - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS - ): LengthAwarePaginator - { - $results = $this->model->paginate($this->getPaginationPerPage($limit), $columns); - $this->resetModel(); - - return $this->handleResults($results); + ): LengthAwarePaginator { + return $this->runQuery( + fn () => $this->handleResults( + $this->model->paginate($this->getPaginationPerPage($limit), $columns) + ) + ); } public function paginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, - ?int $page = null, - ): LengthAwarePaginator - { - $this->applyConditions($where); - $results = $this->model->paginate( - perPage: $this->getPaginationPerPage($limit), - columns: $columns, - page: $page, - ); - $this->resetModel(); + ?int $page = null, + ): LengthAwarePaginator { + return $this->runQuery(function () use ($where, $limit, $columns, $page) { + $this->applyConditions($where); - return $this->handleResults($results); + return $this->handleResults($this->model->paginate( + perPage: $this->getPaginationPerPage($limit), + columns: $columns, + page: $page, + )); + }); } public function simplePaginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, - ): Paginator - { - $this->applyConditions($where); - $results = $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns); - $this->resetModel(); + ): Paginator { + return $this->runQuery(function () use ($where, $limit, $columns) { + $this->applyConditions($where); - return $this->handleResults($results); + return $this->handleResults( + $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns) + ); + }); } public function paginateEloquentRelation( Relation $relation, - ?int $limit = null, - array $columns = self::DEFAULT_COLUMNS - ): LengthAwarePaginator - { - return $this->handleResults($relation->paginate($this->getPaginationPerPage($limit), $columns)); + ?int $limit = null, + array $columns = self::DEFAULT_COLUMNS + ): LengthAwarePaginator { + return $this->runQuery( + fn () => $this->handleResults( + $relation->paginate($this->getPaginationPerPage($limit), $columns) + ) + ); } /** @@ -148,101 +151,94 @@ public function paginateEloquentRelation( */ public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface { - $model = $this->model->findOrFail($id, $columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + return $this->runQuery( + fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns)) + ); } public function findFirstByField( - string $field, + string $field, ?string $value = null, - array $columns = ['*'] - ): ?DomainObjectInterface - { - $model = $this->model->where($field, '=', $value)->first($columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + array $columns = ['*'] + ): ?DomainObjectInterface { + return $this->runQuery( + fn () => $this->handleSingleResult( + $this->model->where($field, '=', $value)->first($columns) + ) + ); } public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface { - $model = $this->model->findOrFail($id, $columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + return $this->runQuery( + fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns)) + ); } public function findWhere( array $where, array $columns = self::DEFAULT_COLUMNS, array $orderAndDirections = [], - ): Collection - { - $this->applyConditions($where); + ): Collection { + return $this->runQuery(function () use ($where, $columns, $orderAndDirections) { + $this->applyConditions($where); - if ($orderAndDirections) { foreach ($orderAndDirections as $orderAndDirection) { $this->model = $this->model->orderBy( $orderAndDirection->getOrder(), $orderAndDirection->getDirection() ); } - } - - $model = $this->model->get($columns); - - $this->resetModel(); - return $this->handleResults($model); + return $this->handleResults($this->model->get($columns)); + }); } public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface { - $this->applyConditions($where); - $model = $this->model->first($columns); - $this->resetModel(); + return $this->runQuery(function () use ($where, $columns) { + $this->applyConditions($where); - return $this->handleSingleResult($model); + return $this->handleSingleResult($this->model->first($columns)); + }); } public function findWhereIn(string $field, array $values, array $additionalWhere = [], array $columns = self::DEFAULT_COLUMNS): Collection { - if ($additionalWhere) { - $this->applyConditions($additionalWhere); - } - - $model = $this->model->whereIn($field, $values)->get($columns); - $this->resetModel(); + return $this->runQuery(function () use ($field, $values, $additionalWhere, $columns) { + if ($additionalWhere) { + $this->applyConditions($additionalWhere); + } - return $this->handleResults($model); + return $this->handleResults($this->model->whereIn($field, $values)->get($columns)); + }); } public function create(array $attributes): DomainObjectInterface { - $model = $this->model->newInstance(collect($attributes)->toArray()); - $model->save(); - $this->resetModel(); + return $this->runQuery(function () use ($attributes) { + $model = $this->model->newInstance(collect($attributes)->toArray()); + $model->save(); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function insert(array $inserts): bool { - // When doing a bulk insert Eloquent doesn't autofill the updated/created dates, - // so we need to do it manually - foreach ($inserts as $index => $insert) { - if (!isset($insert['created_at'], $insert['updated_at'])) { - $now = Carbon::now(); - $inserts[$index]['created_at'] = $now; - $inserts[$index]['updated_at'] = $now; + return $this->runQuery(function () use ($inserts) { + // When doing a bulk insert Eloquent doesn't autofill the updated/created dates, + // so we need to do it manually + foreach ($inserts as $index => $insert) { + if (! isset($insert['created_at'], $insert['updated_at'])) { + $now = Carbon::now(); + $inserts[$index]['created_at'] = $now; + $inserts[$index]['updated_at'] = $now; + } } - } - $insert = $this->model->insert($inserts); - $this->resetModel(); - return $insert; + return $this->model->insert($inserts); + }); } public function updateFromDomainObject(int $id, DomainObjectInterface $domainObject): DomainObjectInterface @@ -252,93 +248,103 @@ public function updateFromDomainObject(int $id, DomainObjectInterface $domainObj public function updateFromArray(int $id, array $attributes): DomainObjectInterface { - $model = $this->model->findOrFail($id); - $model->fill($attributes); - $model->save(); - $this->resetModel(); + return $this->runQuery(function () use ($id, $attributes) { + $model = $this->model->findOrFail($id); + $model->fill($attributes); + $model->save(); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function updateWhere(array $attributes, array $where): int { - $this->applyConditions($where); - $count = $this->model->update($attributes); - $this->resetModel(); + return $this->runQuery(function () use ($attributes, $where) { + $this->applyConditions($where); - return $count; + return $this->model->update($attributes); + }); } public function updateByIdWhere(int $id, array $attributes, array $where): DomainObjectInterface { - $model = $this->model->where($where)->findOrFail($id); - $model->update($attributes); - $this->resetModel(); + return $this->runQuery(function () use ($id, $attributes, $where) { + $model = $this->model->where($where)->findOrFail($id); + $model->update($attributes); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function deleteById(int $id): bool { - return $this->model->findOrFail($id)->delete(); + return $this->runQuery( + fn () => (bool) $this->model->findOrFail($id)->delete() + ); } public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null): int { - if ($where) { - $this->applyConditions($where); - } - - $count = $this->model->incrementEach($columns, $additionalUpdates); - $this->resetModel(); + return $this->runQuery(function () use ($columns, $additionalUpdates, $where) { + if ($where) { + $this->applyConditions($where); + } - return $count; + // Eloquent\Builder's __call swallows incrementEach's int return value + // and hands back the Builder, so we route through the underlying + // QueryBuilder to get the affected-row count. + return $this->resolveBaseQuery()->incrementEach($columns, $additionalUpdates); + }); } public function decrementEach(array $where, array $columns, array $extra = []): int { - $this->applyConditions($where); - $count = $this->model->decrementEach($columns, $extra); - $this->resetModel(); + return $this->runQuery(function () use ($where, $columns, $extra) { + $this->applyConditions($where); - return $count; + return $this->resolveBaseQuery()->decrementEach($columns, $extra); + }); } public function increment(int|float $id, string $column, int|float $amount = 1): int { - return $this->model->findOrFail($id)->increment($column, $amount); + return $this->runQuery( + fn () => $this->model->findOrFail($id)->increment($column, $amount) + ); } public function incrementWhere(array $where, string $column, int|float $amount = 1): int { - $this->applyConditions($where); - $count = $this->model->increment($column, $amount); - $this->resetModel(); + return $this->runQuery(function () use ($where, $column, $amount) { + $this->applyConditions($where); - return $count; + return $this->model->increment($column, $amount); + }); } public function decrement(int|float $id, string $column, int|float $amount = 1): int { - return $this->model->findOrFail($id)?->decrement($column, $amount); + return $this->runQuery( + fn () => $this->model->findOrFail($id)->decrement($column, $amount) + ); } public function deleteWhere(array $conditions): int { - $this->applyConditions($conditions); - $deleted = $this->model->delete(); - $this->resetModel(); + return $this->runQuery(function () use ($conditions) { + $this->applyConditions($conditions); - return $deleted; + return $this->model->delete(); + }); } public function countWhere(array $conditions): int { - $this->applyConditions($conditions); - $count = $this->model->count(); - $this->resetModel(); + return $this->runQuery(function () use ($conditions) { + $this->applyConditions($conditions); - return $count; + return $this->model->count(); + }); } public function loadRelation(string|Relationship $relationship): static @@ -363,7 +369,7 @@ public function includeDeleted(): static protected function applyConditions(array $where): void { foreach ($where as $field => $value) { - if (is_callable($value) && !is_string($value)) { + if (is_callable($value) && ! is_string($value)) { $this->model = $this->model->where($value); } elseif (is_array($value)) { [$field, $condition, $val] = $value; @@ -406,6 +412,48 @@ protected function initModel(?string $model = null): Model return $this->app->make($model ?: $this->getModel()); } + /** + * Execute a query callback and guarantee per-call state is reset afterwards, + * even if the callback throws. This is the single point at which the in-flight + * builder ($this->model) and the eager-load list ($this->eagerLoads) are cleared. + * + * The callback runs BEFORE reset, so hydration helpers that read $this->eagerLoads + * (e.g. handleEagerLoads()) still see the correct state. + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn + */ + protected function runQuery(Closure $callback): mixed + { + try { + return $callback(); + } finally { + $this->resetState(); + } + } + + protected function resetState(): void + { + $model = $this->getModel(); + $this->model = new $model; + $this->eagerLoads = []; + } + + /** + * Resolve $this->model (which may be a fresh Model or an Eloquent Builder + * after applyConditions()) to the underlying query builder. Required for + * methods Eloquent\Builder::__call swallows the return value of, e.g. + * incrementEach() / decrementEach(). + */ + private function resolveBaseQuery(): QueryBuilder + { + return $this->model instanceof Builder + ? $this->model->getQuery() + : $this->model->newQuery()->getQuery(); + } + protected function handleResults($results, ?string $domainObjectOverride = null) { $domainObjects = []; @@ -428,10 +476,9 @@ protected function handleResults($results, ?string $domainObjectOverride = null) protected function handleSingleResult( ?BaseModel $model, - ?string $domainObjectOverride = null - ): ?DomainObjectInterface - { - if (!$model) { + ?string $domainObjectOverride = null + ): ?DomainObjectInterface { + if (! $model) { return null; } @@ -442,11 +489,10 @@ protected function applyFilterFields( QueryParamsDTO $params, array $allowedFilterFields = [], ?string $prefix = null, - ): void - { + ): void { if ($params->filter_fields && $params->filter_fields->isNotEmpty()) { $params->filter_fields->each(function ($filterField) use ($prefix, $allowedFilterFields) { - if (!in_array($filterField->field, $allowedFilterFields, true)) { + if (! in_array($filterField->field, $allowedFilterFields, true)) { return; } @@ -467,7 +513,7 @@ protected function applyFilterFields( sprintf('Operator %s is not supported', $filterField->operator) ); - $field = $prefix ? $prefix . '.' . $filterField->field : $filterField->field; + $field = $prefix ? $prefix.'.'.$filterField->field : $filterField->field; // Special handling for IN operator if ($operator === 'IN') { @@ -491,10 +537,13 @@ protected function applyFilterFields( } } + /** + * @deprecated Use resetState() instead. Kept for backwards compatibility with + * subclass repositories that build custom queries on $this->model. + */ protected function resetModel(): void { - $model = $this->getModel(); - $this->model = new $model(); + $this->resetState(); } private function getPaginationPerPage(?int $perPage): int @@ -503,30 +552,26 @@ private function getPaginationPerPage(?int $perPage): int $perPage = self::DEFAULT_PAGINATE_LIMIT; } - return (int)min($perPage, $this->maxPerPage); + return (int) min($perPage, $this->maxPerPage); } /** - * @param Model $model - * @param string|null $domainObjectOverride A FQCN of a DO - * @param array|null $relationships - * @return DomainObjectInterface + * @param string|null $domainObjectOverride A FQCN of a DO * * @todo use hydrate method from AbstractDomainObject */ private function hydrateDomainObjectFromModel( - Model $model, + Model $model, ?string $domainObjectOverride = null, - ?array $relationships = null, - ): DomainObjectInterface - { + ?array $relationships = null, + ): DomainObjectInterface { /** @var DomainObjectInterface $object */ $object = $domainObjectOverride ?: $this->getDomainObject(); - $object = new $object(); + $object = new $object; foreach ($model->attributesToArray() as $attribute => $value) { - $method = 'set' . ucfirst(Str::camel($attribute)); - if (is_callable(array($object, $method))) { + $method = 'set'.Str::studly($attribute); + if (is_callable([$object, $method])) { try { $object->$method($value); } catch (TypeError $e) { @@ -538,7 +583,7 @@ private function hydrateDomainObjectFromModel( var_export($value, true), $e->getMessage() ), - (int)$e->getCode(), + (int) $e->getCode(), $e ); } @@ -554,24 +599,20 @@ private function hydrateDomainObjectFromModel( /** * This method will handle nested eager loading of relationships * - * @param Model $model - * @param DomainObjectInterface $object - * @param Relationship[]|null $relationships - * - * @return void + * @param Relationship[]|null $relationships */ private function handleEagerLoads(Model $model, DomainObjectInterface $object, ?array $relationships): void { $eagerLoads = $relationships ?: $this->eagerLoads; foreach ($eagerLoads as $eagerLoad) { - if (!$model->relationLoaded($eagerLoad->getName())) { + if (! $model->relationLoaded($eagerLoad->getName())) { continue; } $relatedModels = $model->getRelation($eagerLoad->getName()); - $setterMethod = 'set' . Str::studly($eagerLoad->getName()); + $setterMethod = 'set'.Str::studly($eagerLoad->getName()); - if (!is_callable([$object, $setterMethod])) { + if (! is_callable([$object, $setterMethod])) { throw new BadMethodCallException( sprintf( 'Method %s is not callable on %s. Does it exist?', @@ -590,7 +631,7 @@ private function handleEagerLoads(Model $model, DomainObjectInterface $object, ? ); }); $object->$setterMethod($relatedDomainObjects); - } else if ($relatedModels instanceof BaseModel) { + } elseif ($relatedModels instanceof BaseModel) { $relatedDomainObject = $this->hydrateDomainObjectFromModel( $relatedModels, $eagerLoad->getDomainObject(), diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 8fec6e6b02..0d665f89e7 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -22,7 +22,7 @@ - + diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php index 2932d4a69d..621ce4dc06 100644 --- a/backend/tests/TestCase.php +++ b/backend/tests/TestCase.php @@ -3,8 +3,45 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use RuntimeException; abstract class TestCase extends BaseTestCase { use CreatesApplication; + + protected function setUp(): void + { + parent::setUp(); + + $this->guardAgainstNonTestDatabase(); + } + + /** + * Hard safety net: any test that boots Laravel could (intentionally or not) + * issue queries against the configured database. Refuse to run unless the + * default connection's database name ends in "_test" so a misconfigured + * environment can never touch a dev/staging/prod database. + * + * The check reads config only — it does not open a connection — so tests + * that never touch the database still pay only a constant-time cost and + * never fail because the test database is unreachable. + * + * Marked final so individual tests cannot bypass it. + */ + final protected function guardAgainstNonTestDatabase(): void + { + $defaultConnection = config('database.default'); + $database = config("database.connections.{$defaultConnection}.database"); + + if (! is_string($database) || ! str_ends_with($database, '_test')) { + throw new RuntimeException(sprintf( + 'Refusing to run %s: default database connection "%s" points at "%s", ' + .'which does not end in "_test". Set DB_DATABASE to a *_test database ' + .'(CI uses hievents_test; locally configured via backend/.env.testing).', + static::class, + (string) $defaultConnection, + (string) $database, + )); + } + } } diff --git a/backend/tests/Unit/Repository/BaseRepositoryTest.php b/backend/tests/Unit/Repository/BaseRepositoryTest.php new file mode 100644 index 0000000000..63a77a12e2 --- /dev/null +++ b/backend/tests/Unit/Repository/BaseRepositoryTest.php @@ -0,0 +1,725 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('br_test_widgets', function (Blueprint $table) { + $table->id(); + $table->foreignId('category_id')->nullable(); + $table->string('name'); + $table->string('sku')->nullable(); + $table->integer('quantity')->default(0); + $table->decimal('price', 10, 2)->default(0); + $table->boolean('is_active')->default(true); + $table->text('description')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->repository = $this->app->make(WidgetRepository::class); + $this->categoryRepository = $this->app->make(WidgetCategoryRepository::class); + } + + protected function tearDown(): void + { + Schema::dropIfExists('br_test_widgets'); + Schema::dropIfExists('br_test_widget_categories'); + + parent::tearDown(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private function makeCategory(string $name = 'Default'): WidgetCategoryModel + { + $category = new WidgetCategoryModel; + $category->name = $name; + $category->save(); + + return $category; + } + + private function makeWidget(array $overrides = []): WidgetModel + { + $widget = new WidgetModel; + $widget->fill(array_merge([ + 'name' => 'Widget '.uniqid('', true), + 'sku' => 'SKU-'.uniqid('', true), + 'quantity' => 10, + 'price' => 9.99, + 'is_active' => true, + 'category_id' => null, + ], $overrides)); + $widget->save(); + + return $widget; + } + + // ───────────────────────────────────────────────────────────────────────── + // create / insert + // ───────────────────────────────────────────────────────────────────────── + + public function test_create_inserts_a_row_and_hydrates_a_domain_object(): void + { + $widget = $this->repository->create([ + 'name' => 'Sprocket', + 'sku' => 'SP-001', + 'quantity' => 5, + 'price' => 12.50, + 'is_active' => true, + ]); + + $this->assertInstanceOf(WidgetDomainObject::class, $widget); + $this->assertNotNull($widget->getId()); + $this->assertSame('Sprocket', $widget->getName()); + $this->assertSame(5, $widget->getQuantity()); + $this->assertSame(12.50, $widget->getPrice()); + $this->assertTrue($widget->getIsActive()); + + $this->assertDatabaseHas('br_test_widgets', ['sku' => 'SP-001']); + } + + public function test_insert_bulk_inserts_rows_and_autofills_timestamps(): void + { + $result = $this->repository->insert([ + ['name' => 'A', 'sku' => 'A-1', 'quantity' => 1, 'price' => 1, 'is_active' => true], + ['name' => 'B', 'sku' => 'B-1', 'quantity' => 2, 'price' => 2, 'is_active' => true], + ]); + + $this->assertTrue($result); + $this->assertSame(2, WidgetModel::query()->count()); + // both rows should have timestamps populated by the base repository + $this->assertSame(0, WidgetModel::query()->whereNull('created_at')->count()); + $this->assertSame(0, WidgetModel::query()->whereNull('updated_at')->count()); + } + + public function test_insert_preserves_caller_supplied_timestamps(): void + { + $supplied = '2020-01-01 00:00:00'; + + $this->repository->insert([ + [ + 'name' => 'A', + 'sku' => 'A-1', + 'quantity' => 1, + 'price' => 1, + 'is_active' => true, + 'created_at' => $supplied, + 'updated_at' => $supplied, + ], + ]); + + $this->assertSame(1, WidgetModel::query()->where('created_at', $supplied)->count()); + } + + // ───────────────────────────────────────────────────────────────────────── + // findById / findFirst / findFirstByField / findFirstWhere + // ───────────────────────────────────────────────────────────────────────── + + public function test_find_by_id_returns_hydrated_domain_object(): void + { + $widget = $this->makeWidget(['name' => 'Cog']); + + $found = $this->repository->findById($widget->id); + + $this->assertInstanceOf(WidgetDomainObject::class, $found); + $this->assertSame($widget->id, $found->getId()); + $this->assertSame('Cog', $found->getName()); + } + + public function test_find_by_id_throws_when_missing(): void + { + $this->expectException(ModelNotFoundException::class); + $this->repository->findById(999_999); + } + + public function test_find_first_returns_domain_object_when_present(): void + { + $widget = $this->makeWidget(['name' => 'Hinge']); + + $found = $this->repository->findFirst($widget->id); + + $this->assertNotNull($found); + $this->assertSame('Hinge', $found->getName()); + } + + public function test_find_first_by_field_returns_match(): void + { + $this->makeWidget(['sku' => 'UNIQ-1']); + + $found = $this->repository->findFirstByField('sku', 'UNIQ-1'); + + $this->assertNotNull($found); + $this->assertSame('UNIQ-1', $found->getSku()); + } + + public function test_find_first_by_field_returns_null_when_no_match(): void + { + $found = $this->repository->findFirstByField('sku', 'does-not-exist'); + + $this->assertNull($found); + } + + public function test_find_first_where_returns_first_matching_row(): void + { + $this->makeWidget(['name' => 'A', 'is_active' => false]); + $this->makeWidget(['name' => 'B', 'is_active' => true]); + + $found = $this->repository->findFirstWhere(['is_active' => true]); + + $this->assertNotNull($found); + $this->assertSame('B', $found->getName()); + } + + public function test_find_first_where_returns_null_when_no_match(): void + { + $this->makeWidget(['is_active' => true]); + + $this->assertNull($this->repository->findFirstWhere(['is_active' => false])); + } + + // ───────────────────────────────────────────────────────────────────────── + // findWhere / findWhereIn / all / countWhere + // ───────────────────────────────────────────────────────────────────────── + + public function test_find_where_returns_collection_of_domain_objects(): void + { + $this->makeWidget(['name' => 'A', 'is_active' => true]); + $this->makeWidget(['name' => 'B', 'is_active' => true]); + $this->makeWidget(['name' => 'C', 'is_active' => false]); + + $results = $this->repository->findWhere(['is_active' => true]); + + $this->assertInstanceOf(Collection::class, $results); + $this->assertCount(2, $results); + $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $results); + } + + public function test_find_where_orders_results_using_order_and_directions(): void + { + $this->makeWidget(['name' => 'B']); + $this->makeWidget(['name' => 'A']); + $this->makeWidget(['name' => 'C']); + + $results = $this->repository->findWhere( + where: [], + orderAndDirections: [new OrderAndDirection('name', 'asc')], + ); + + $names = $results->map(fn (WidgetDomainObject $w) => $w->getName())->all(); + $this->assertSame(['A', 'B', 'C'], $names); + } + + public function test_find_where_in_filters_by_inclusion_with_additional_where(): void + { + $w1 = $this->makeWidget(['name' => 'X', 'is_active' => true]); + $w2 = $this->makeWidget(['name' => 'Y', 'is_active' => false]); + $this->makeWidget(['name' => 'Z', 'is_active' => true]); + + $results = $this->repository->findWhereIn( + field: 'id', + values: [$w1->id, $w2->id], + additionalWhere: ['is_active' => true], + ); + + $this->assertCount(1, $results); + $this->assertSame('X', $results->first()->getName()); + } + + public function test_all_returns_every_row(): void + { + $this->makeWidget(); + $this->makeWidget(); + $this->makeWidget(); + + $this->assertCount(3, $this->repository->all()); + } + + public function test_count_where_counts_matching_rows(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $this->assertSame(2, $this->repository->countWhere(['is_active' => true])); + $this->assertSame(3, $this->repository->countWhere([])); + } + + // ───────────────────────────────────────────────────────────────────────── + // applyConditions DSL + // ───────────────────────────────────────────────────────────────────────── + + public function test_apply_conditions_supports_in_operator(): void + { + $a = $this->makeWidget(); + $b = $this->makeWidget(); + $this->makeWidget(); + + $results = $this->repository->findWhere([ + ['id', 'in', [$a->id, $b->id]], + ]); + + $this->assertCount(2, $results); + } + + public function test_apply_conditions_supports_not_in_operator(): void + { + $a = $this->makeWidget(); + $this->makeWidget(); + $this->makeWidget(); + + $results = $this->repository->findWhere([ + ['id', 'not in', [$a->id]], + ]); + + $this->assertCount(2, $results); + } + + public function test_apply_conditions_supports_null_operator(): void + { + $this->makeWidget(['description' => null]); + $this->makeWidget(['description' => 'has text']); + + $results = $this->repository->findWhere([ + ['description', 'null', null], + ]); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_not_null_operator(): void + { + $this->makeWidget(['description' => null]); + $this->makeWidget(['description' => 'has text']); + + $results = $this->repository->findWhere([ + ['description', 'not null', null], + ]); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_comparison_operators(): void + { + $this->makeWidget(['quantity' => 5]); + $this->makeWidget(['quantity' => 10]); + $this->makeWidget(['quantity' => 15]); + + $this->assertCount(2, $this->repository->findWhere([['quantity', '>=', 10]])); + $this->assertCount(1, $this->repository->findWhere([['quantity', '<', 10]])); + $this->assertCount(1, $this->repository->findWhere([['quantity', '=', 15]])); + } + + public function test_apply_conditions_treats_simple_pairs_as_equality(): void + { + $this->makeWidget(['name' => 'foo']); + $this->makeWidget(['name' => 'bar']); + + $results = $this->repository->findWhere(['name' => 'foo']); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_callable_value(): void + { + $this->makeWidget(['name' => 'foo', 'is_active' => true]); + $this->makeWidget(['name' => 'bar', 'is_active' => true]); + $this->makeWidget(['name' => 'foo', 'is_active' => false]); + + $results = $this->repository->findWhere([ + 'name' => 'foo', + // closure-as-value path through applyConditions + fn ($q) => $q->where('is_active', true), + ]); + + $this->assertCount(1, $results); + } + + // ───────────────────────────────────────────────────────────────────────── + // update / delete + // ───────────────────────────────────────────────────────────────────────── + + public function test_update_from_array_persists_changes_and_returns_fresh_object(): void + { + $widget = $this->makeWidget(['name' => 'old', 'quantity' => 1]); + + $updated = $this->repository->updateFromArray($widget->id, [ + 'name' => 'new', + 'quantity' => 99, + ]); + + $this->assertSame('new', $updated->getName()); + $this->assertSame(99, $updated->getQuantity()); + $this->assertDatabaseHas('br_test_widgets', ['id' => $widget->id, 'name' => 'new']); + } + + public function test_update_where_returns_affected_count(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $affected = $this->repository->updateWhere( + attributes: ['name' => 'renamed'], + where: ['is_active' => true], + ); + + $this->assertSame(2, $affected); + $this->assertSame(2, WidgetModel::query()->where('name', 'renamed')->count()); + } + + public function test_update_by_id_where_updates_when_predicate_matches(): void + { + $widget = $this->makeWidget(['is_active' => true, 'name' => 'old']); + + $updated = $this->repository->updateByIdWhere( + id: $widget->id, + attributes: ['name' => 'new'], + where: ['is_active' => true], + ); + + $this->assertSame('new', $updated->getName()); + } + + public function test_update_by_id_where_throws_when_predicate_does_not_match(): void + { + $widget = $this->makeWidget(['is_active' => true]); + + $this->expectException(ModelNotFoundException::class); + $this->repository->updateByIdWhere( + id: $widget->id, + attributes: ['name' => 'new'], + where: ['is_active' => false], + ); + } + + public function test_delete_by_id_soft_deletes_the_row(): void + { + $widget = $this->makeWidget(); + + $this->assertTrue($this->repository->deleteById($widget->id)); + $this->assertSoftDeleted('br_test_widgets', ['id' => $widget->id]); + } + + public function test_delete_where_returns_affected_count(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $deleted = $this->repository->deleteWhere(['is_active' => true]); + + $this->assertSame(2, $deleted); + } + + // ───────────────────────────────────────────────────────────────────────── + // increment / decrement + // ───────────────────────────────────────────────────────────────────────── + + public function test_increment_bumps_an_integer_column(): void + { + $widget = $this->makeWidget(['quantity' => 10]); + + $this->repository->increment($widget->id, 'quantity', 3); + + $this->assertSame(13, (int) WidgetModel::query()->find($widget->id)->quantity); + } + + public function test_increment_supports_float_amount(): void + { + $widget = $this->makeWidget(['price' => 10.00]); + + $this->repository->increment($widget->id, 'price', 2.50); + + $this->assertSame(12.50, (float) WidgetModel::query()->find($widget->id)->price); + } + + public function test_decrement_lowers_an_integer_column(): void + { + $widget = $this->makeWidget(['quantity' => 10]); + + $this->repository->decrement($widget->id, 'quantity', 4); + + $this->assertSame(6, (int) WidgetModel::query()->find($widget->id)->quantity); + } + + public function test_increment_where_bumps_matching_rows(): void + { + $a = $this->makeWidget(['quantity' => 1, 'is_active' => true]); + $b = $this->makeWidget(['quantity' => 1, 'is_active' => true]); + $c = $this->makeWidget(['quantity' => 1, 'is_active' => false]); + + $this->repository->incrementWhere(['is_active' => true], 'quantity', 5); + + $this->assertSame(6, (int) WidgetModel::query()->find($a->id)->quantity); + $this->assertSame(6, (int) WidgetModel::query()->find($b->id)->quantity); + $this->assertSame(1, (int) WidgetModel::query()->find($c->id)->quantity); + } + + public function test_increment_each_updates_multiple_columns(): void + { + $widget = $this->makeWidget(['quantity' => 1, 'price' => 1.00]); + + $this->repository->incrementEach( + columns: ['quantity' => 2, 'price' => 3.00], + where: ['id' => $widget->id], + ); + + $fresh = WidgetModel::query()->find($widget->id); + $this->assertSame(3, (int) $fresh->quantity); + $this->assertSame(4.00, (float) $fresh->price); + } + + public function test_decrement_each_updates_multiple_columns(): void + { + $widget = $this->makeWidget(['quantity' => 10, 'price' => 10.00]); + + $this->repository->decrementEach( + where: ['id' => $widget->id], + columns: ['quantity' => 2, 'price' => 1.00], + ); + + $fresh = WidgetModel::query()->find($widget->id); + $this->assertSame(8, (int) $fresh->quantity); + $this->assertSame(9.00, (float) $fresh->price); + } + + // ───────────────────────────────────────────────────────────────────────── + // Pagination + // ───────────────────────────────────────────────────────────────────────── + + public function test_paginate_returns_a_length_aware_paginator(): void + { + for ($i = 0; $i < 5; $i++) { + $this->makeWidget(); + } + + $page = $this->repository->paginate(limit: 2); + + $this->assertInstanceOf(LengthAwarePaginator::class, $page); + $this->assertSame(5, $page->total()); + $this->assertCount(2, $page->items()); + $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $page->items()); + } + + public function test_paginate_where_filters_then_paginates(): void + { + for ($i = 0; $i < 3; $i++) { + $this->makeWidget(['is_active' => true]); + } + $this->makeWidget(['is_active' => false]); + + $page = $this->repository->paginateWhere(['is_active' => true], limit: 2); + + $this->assertSame(3, $page->total()); + $this->assertCount(2, $page->items()); + } + + public function test_simple_paginate_where_returns_a_simple_paginator(): void + { + for ($i = 0; $i < 4; $i++) { + $this->makeWidget(['is_active' => true]); + } + + $page = $this->repository->simplePaginateWhere(['is_active' => true], limit: 2); + + $this->assertInstanceOf(Paginator::class, $page); + $this->assertCount(2, $page->items()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Eager loading + // ───────────────────────────────────────────────────────────────────────── + + public function test_load_relation_hydrates_a_belongs_to_relation(): void + { + $category = $this->makeCategory('Tools'); + $widget = $this->makeWidget(['category_id' => $category->id]); + + $found = $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById($widget->id); + + $this->assertNotNull($found->getCategory()); + $this->assertInstanceOf(WidgetCategoryDomainObject::class, $found->getCategory()); + $this->assertSame('Tools', $found->getCategory()->getName()); + } + + public function test_load_relation_hydrates_a_has_many_relation_as_a_collection(): void + { + $category = $this->makeCategory('Bolts'); + $this->makeWidget(['category_id' => $category->id, 'name' => 'M3']); + $this->makeWidget(['category_id' => $category->id, 'name' => 'M4']); + + $found = $this->categoryRepository + ->loadRelation(new Relationship(WidgetDomainObject::class, name: 'widgets')) + ->findById($category->id); + + $this->assertInstanceOf(Collection::class, $found->getWidgets()); + $this->assertCount(2, $found->getWidgets()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Soft deletes / includeDeleted + // ───────────────────────────────────────────────────────────────────────── + + public function test_include_deleted_returns_soft_deleted_rows(): void + { + $widget = $this->makeWidget(); + $this->repository->deleteById($widget->id); + + $this->assertNull($this->repository->findFirstWhere(['id' => $widget->id])); + + $found = $this->repository->includeDeleted()->findFirstWhere(['id' => $widget->id]); + $this->assertNotNull($found); + $this->assertSame($widget->id, $found->getId()); + } + + // ───────────────────────────────────────────────────────────────────────── + // State reset (the actual point of the refactor) + // ───────────────────────────────────────────────────────────────────────── + + public function test_consecutive_finds_do_not_leak_where_clauses(): void + { + $a = $this->makeWidget(['is_active' => true]); + $b = $this->makeWidget(['is_active' => false]); + + // First call applies a where(is_active, true) + $first = $this->repository->findWhere(['is_active' => true]); + $this->assertCount(1, $first); + + // Second call must NOT inherit the previous where clause + $second = $this->repository->findWhere([]); + $this->assertCount(2, $second, 'Second findWhere([]) inherited state from the previous query'); + } + + public function test_eager_loads_are_reset_between_queries(): void + { + $category = $this->makeCategory('Cat'); + $widgetA = $this->makeWidget(['category_id' => $category->id]); + $widgetB = $this->makeWidget(['category_id' => $category->id]); + + $first = $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById($widgetA->id); + $this->assertNotNull($first->getCategory()); + + // After the call, eagerLoads MUST be cleared. Previously this was a bug — + // resetModel() reset the builder but left $eagerLoads populated, so the + // array would grow unboundedly across calls on the same instance. + $this->assertSame([], $this->repository->exposeEagerLoads()); + + // A subsequent call without loadRelation() must produce an unhydrated relation. + $second = $this->repository->findById($widgetB->id); + $this->assertNull($second->getCategory()); + } + + public function test_state_is_reset_even_when_the_query_throws(): void + { + $this->makeWidget(['is_active' => true]); + + try { + // findById on a missing id throws ModelNotFoundException — but only + // AFTER the loadRelation call has registered an eager load and added + // a where clause. + $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById(999_999); + $this->fail('Expected ModelNotFoundException'); + } catch (ModelNotFoundException) { + // expected + } + + // The next call on the same repository instance must start clean. + $this->assertSame([], $this->repository->exposeEagerLoads()); + $this->assertFalse($this->repository->exposeBuilderHasWheres()); + } + + public function test_set_max_per_page_caps_pagination_size(): void + { + for ($i = 0; $i < 10; $i++) { + $this->makeWidget(); + } + + $page = $this->repository->setMaxPerPage(3)->paginate(limit: 100); + + $this->assertCount(3, $page->items()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Hydration edge cases + // ───────────────────────────────────────────────────────────────────────── + + public function test_hydration_calls_setters_via_studly_case(): void + { + // category_id is a snake_case column → setCategoryId on the domain object + $category = $this->makeCategory(); + $widget = $this->makeWidget(['category_id' => $category->id]); + + $found = $this->repository->findById($widget->id); + + $this->assertSame($category->id, $found->getCategoryId()); + } + + public function test_hydration_silently_skips_columns_with_no_setter(): void + { + // No setter exists on WidgetDomainObject for an unknown column. + // Add a column on the fly via raw SQL so the model picks it up. + Schema::table('br_test_widgets', function (Blueprint $table) { + $table->string('mystery_field')->nullable(); + }); + + $widget = $this->makeWidget(); + WidgetModel::query()->where('id', $widget->id)->update(['mystery_field' => 'something']); + + // Should not throw — the silent-skip behaviour is documented. + $found = $this->repository->findById($widget->id); + $this->assertNotNull($found); + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetCategoryDomainObject.php b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryDomainObject.php new file mode 100644 index 0000000000..9a70807625 --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryDomainObject.php @@ -0,0 +1,65 @@ +id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setWidgets(?Collection $widgets): self + { + $this->widgets = $widgets; + + return $this; + } + + public function getWidgets(): ?Collection + { + return $this->widgets; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + ]; + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetCategoryModel.php b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryModel.php new file mode 100644 index 0000000000..a648fa2b1e --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryModel.php @@ -0,0 +1,23 @@ +hasMany(WidgetModel::class, 'category_id'); + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetCategoryRepository.php b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryRepository.php new file mode 100644 index 0000000000..15de1c05bf --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryRepository.php @@ -0,0 +1,23 @@ + + */ +class WidgetCategoryRepository extends BaseRepository +{ + protected function getModel(): string + { + return WidgetCategoryModel::class; + } + + public function getDomainObject(): string + { + return WidgetCategoryDomainObject::class; + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetDomainObject.php b/backend/tests/Unit/Repository/Fixtures/WidgetDomainObject.php new file mode 100644 index 0000000000..e4dd4da2c0 --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetDomainObject.php @@ -0,0 +1,199 @@ +id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setCategoryId(?int $category_id): self + { + $this->category_id = $category_id; + + return $this; + } + + public function getCategoryId(): ?int + { + return $this->category_id; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setSku(?string $sku): self + { + $this->sku = $sku; + + return $this; + } + + public function getSku(): ?string + { + return $this->sku; + } + + public function setQuantity(?int $quantity): self + { + $this->quantity = $quantity; + + return $this; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setPrice(float|int|null $price): self + { + $this->price = $price === null ? null : (float) $price; + + return $this; + } + + public function getPrice(): ?float + { + return $this->price; + } + + public function setIsActive(?bool $is_active): self + { + $this->is_active = $is_active; + + return $this; + } + + public function getIsActive(): ?bool + { + return $this->is_active; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setCategory(?WidgetCategoryDomainObject $category): self + { + $this->category = $category; + + return $this; + } + + public function getCategory(): ?WidgetCategoryDomainObject + { + return $this->category; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'category_id' => $this->category_id, + 'name' => $this->name, + 'sku' => $this->sku, + 'quantity' => $this->quantity, + 'price' => $this->price, + 'is_active' => $this->is_active, + 'description' => $this->description, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'deleted_at' => $this->deleted_at, + ]; + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetModel.php b/backend/tests/Unit/Repository/Fixtures/WidgetModel.php new file mode 100644 index 0000000000..9be9e0eaa6 --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetModel.php @@ -0,0 +1,43 @@ + 'boolean', + 'quantity' => 'integer', + 'price' => 'float', + ]; + } + + public function category(): BelongsTo + { + return $this->belongsTo(WidgetCategoryModel::class, 'category_id'); + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetRepository.php b/backend/tests/Unit/Repository/Fixtures/WidgetRepository.php new file mode 100644 index 0000000000..1cf6c436fb --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetRepository.php @@ -0,0 +1,44 @@ + + */ +class WidgetRepository extends BaseRepository +{ + protected function getModel(): string + { + return WidgetModel::class; + } + + public function getDomainObject(): string + { + return WidgetDomainObject::class; + } + + /** + * Test hooks: expose protected state so we can assert reset behaviour + * without resorting to reflection. + */ + public function exposeEagerLoads(): array + { + return $this->eagerLoads; + } + + public function exposeBuilderHasWheres(): bool + { + $base = $this->model->getQuery(); + + return ! empty($base->wheres); + } +} diff --git a/docker/development/docker-compose.dev.yml b/docker/development/docker-compose.dev.yml index 1c605156cd..ba5683104d 100644 --- a/docker/development/docker-compose.dev.yml +++ b/docker/development/docker-compose.dev.yml @@ -109,8 +109,11 @@ services: POSTGRES_DB: '${DB_DATABASE}' POSTGRES_USER: '${DB_USERNAME}' POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' + TEST_DB_NAME: '${TEST_DB_NAME:-hievents_test}' volumes: - 'app-pgsql:/var/lib/postgresql/data' + # Init scripts run once on a fresh data volume — creates hievents_test. + - './pgsql-init:/docker-entrypoint-initdb.d:ro' networks: - app healthcheck: diff --git a/docker/development/pgsql-init/01-create-test-db.sh b/docker/development/pgsql-init/01-create-test-db.sh new file mode 100755 index 0000000000..0fa09e4cdd --- /dev/null +++ b/docker/development/pgsql-init/01-create-test-db.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Postgres entrypoint init script — runs once on a fresh data volume. +# Creates the hievents_test database used by the test suite (the BaseRepositoryTest +# guard refuses to run against any database whose name does not end in _test). +# +# Idempotent: existing test DBs are left alone. + +set -euo pipefail + +TEST_DB="${TEST_DB_NAME:-hievents_test}" + +psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL + SELECT 'CREATE DATABASE ${TEST_DB} OWNER ${POSTGRES_USER}' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${TEST_DB}')\gexec +EOSQL + +echo "Test database '${TEST_DB}' is ready." diff --git a/docker/development/start-dev.sh b/docker/development/start-dev.sh index 599ae442f8..53a3289a07 100755 --- a/docker/development/start-dev.sh +++ b/docker/development/start-dev.sh @@ -5,36 +5,95 @@ CERTS_FLAG="$1" RED='\033[0;31m' GREEN='\033[0;32m' -BG_BLACK='\033[40m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +BOLD='\033[1m' +DIM='\033[2m' NC='\033[0m' # No Color CERTS_DIR="./certs" -echo -e "${GREEN}${BG_BLACK}Installing Hi.Events...${NC}" +print_banner() { + echo "" + echo -e "${CYAN}${BOLD} ╔═══════════════════════════════════════════╗${NC}" + echo -e "${CYAN}${BOLD} ║ ║${NC}" + echo -e "${CYAN}${BOLD} ║ ${MAGENTA}Hi.Events Dev Launcher${CYAN} ║${NC}" + echo -e "${CYAN}${BOLD} ║ ║${NC}" + echo -e "${CYAN}${BOLD} ╚═══════════════════════════════════════════╝${NC}" + echo "" +} + +step() { + echo -e "${BLUE}${BOLD}▶${NC} ${BOLD}$1${NC}" +} + +info() { + echo -e " ${DIM}$1${NC}" +} + +ok() { + echo -e " ${GREEN}✓${NC} $1" +} + +warn() { + echo -e " ${YELLOW}⚠${NC} $1" +} + +fail() { + echo -e " ${RED}✗${NC} $1" +} + +# Prompt yes/no. $1 = question, $2 = default ("y" or "n") +ask_yes_no() { + local prompt="$1" + local default="$2" + local hint + if [ "$default" = "y" ]; then + hint="${BOLD}Y${NC}/n" + else + hint="y/${BOLD}N${NC}" + fi + while true; do + echo -ne "${YELLOW}?${NC} ${BOLD}$prompt${NC} [$hint] " + read -r reply + reply="${reply:-$default}" + case "$reply" in + [Yy]*) return 0 ;; + [Nn]*) return 1 ;; + *) echo -e " ${DIM}Please answer y or n.${NC}" ;; + esac + done +} + +print_banner mkdir -p "$CERTS_DIR" generate_unsigned_certs() { if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then - echo -e "${GREEN}Generating unsigned SSL certificates...${NC}" - openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" + step "Generating unsigned SSL certificates" + openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" > /dev/null 2>&1 + ok "Certificates generated" else - echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}" + ok "SSL certificates already exist" fi } generate_signed_certs() { if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then if ! command -v mkcert &> /dev/null; then - echo -e "${RED}mkcert is not installed.${NC}" - echo "Please install mkcert by following the instructions at: https://github.com/FiloSottile/mkcert#installation" - echo "Alternatively, you can generate unsigned certificates by using '--certs=unsigned' or omitting the --certs flag." + fail "mkcert is not installed." + info "Install via https://github.com/FiloSottile/mkcert#installation" + info "Or use unsigned certs: '--certs=unsigned' (or omit --certs)" exit 1 else - echo -e "${GREEN}Generating signed SSL certificates with mkcert...${NC}" - mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 + step "Generating signed SSL certificates with mkcert" + mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 > /dev/null 2>&1 + ok "Certificates generated" fi else - echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}" + ok "SSL certificates already exist" fi } @@ -47,33 +106,72 @@ case "$CERTS_FLAG" in ;; esac -$COMPOSE_CMD up -d +echo "" +step "Setup options" -if [ $? -ne 0 ]; then - echo -e "${RED}Failed to start services with docker-compose.${NC}" - exit 1 +WIPE_DB=false +if ask_yes_no "Wipe the database and start fresh?" "n"; then + WIPE_DB=true + warn "Database will be wiped on startup" +else + info "Keeping existing database" fi -echo -e "${GREEN}Running composer install in the backend service...${NC}" +REINSTALL_DEPS=true +if ask_yes_no "Reinstall frontend dependencies (yarn install)?" "y"; then + REINSTALL_DEPS=true + info "Frontend image will be rebuilt with fresh deps" +else + REINSTALL_DEPS=false + info "Skipping frontend dependency reinstall" +fi + +echo "" + +if [ "$WIPE_DB" = true ]; then + step "Tearing down existing containers and volumes" + $COMPOSE_CMD down -v > /dev/null 2>&1 + ok "Containers and volumes removed" +elif [ "$REINSTALL_DEPS" = true ]; then + step "Removing frontend container to refresh node_modules" + $COMPOSE_CMD rm -sfv frontend > /dev/null 2>&1 + ok "Frontend container removed" +fi + +if [ "$REINSTALL_DEPS" = true ]; then + step "Rebuilding frontend image (running yarn install)" + if ! $COMPOSE_CMD build frontend; then + fail "Frontend image build failed" + exit 1 + fi + ok "Frontend image rebuilt" +fi + +step "Starting services" +if ! $COMPOSE_CMD up -d; then + fail "Failed to start services with docker compose." + exit 1 +fi +ok "Services started" -$COMPOSE_CMD exec -T backend composer install \ +step "Running composer install in the backend service" +if ! $COMPOSE_CMD exec -T backend composer install \ --ignore-platform-reqs \ --no-interaction \ --optimize-autoloader \ - --prefer-dist - -if [ $? -ne 0 ]; then - echo -e "${RED}Composer install failed within the backend service.${NC}" + --prefer-dist; then + fail "Composer install failed within the backend service." exit 1 fi +ok "Composer dependencies installed" -echo -e "${GREEN}Waiting for the database to be ready...${NC}" -while ! $COMPOSE_CMD logs pgsql | grep "ready to accept connections" > /dev/null; do - echo -n '.' - sleep 1 +step "Waiting for the database to be ready" +while ! $COMPOSE_CMD logs pgsql 2>/dev/null | grep "ready to accept connections" > /dev/null; do + echo -n '.' + sleep 1 done - -echo -e "\n${GREEN}Database is ready. Proceeding with migrations...${NC}" +echo "" +ok "Database is ready" if [ ! -f ./../../backend/.env ]; then $COMPOSE_CMD exec backend cp .env.example .env @@ -83,17 +181,40 @@ if [ ! -f ./../../frontend/.env ]; then $COMPOSE_CMD exec frontend cp .env.example .env fi +step "Running migrations and setup" $COMPOSE_CMD exec backend php artisan key:generate $COMPOSE_CMD exec backend php artisan migrate $COMPOSE_CMD exec backend chmod -R 775 /var/www/html/vendor/ezyang/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer $COMPOSE_CMD exec backend php artisan storage:link if [ $? -ne 0 ]; then - echo -e "${RED}Migrations failed.${NC}" + fail "Migrations failed." exit 1 fi +ok "Migrations complete" + +echo "" +step "Background workers" + +if ask_yes_no "Start the queue worker?" "y"; then + $COMPOSE_CMD exec -d backend php artisan queue:work --queue=default,webhook-queue --sleep=3 --tries=3 --timeout=60 + ok "Queue worker started (detached)" +else + info "Skipped queue worker — start it later with:" + info "$COMPOSE_CMD exec backend php artisan queue:work" +fi + +if ask_yes_no "Start the scheduler?" "y"; then + $COMPOSE_CMD exec -d backend php artisan schedule:work + ok "Scheduler started (detached)" +else + info "Skipped scheduler — start it later with:" + info "$COMPOSE_CMD exec backend php artisan schedule:work" +fi -echo -e "${GREEN}Hi.Events is now running at:${NC} https://localhost:8443" +echo "" +echo -e "${GREEN}${BOLD} 🎉 Hi.Events is now running at:${NC} ${CYAN}${BOLD}https://localhost:8443${NC}" +echo "" case "$(uname -s)" in Darwin) open https://localhost:8443/auth/register ;; diff --git a/frontend/src/components/layouts/AuthLayout/Auth.module.scss b/frontend/src/components/layouts/AuthLayout/Auth.module.scss index 694c1cd42b..399d9e8581 100644 --- a/frontend/src/components/layouts/AuthLayout/Auth.module.scss +++ b/frontend/src/components/layouts/AuthLayout/Auth.module.scss @@ -5,7 +5,6 @@ min-height: 100vh; display: flex; position: relative; - overflow: hidden; } .splitLayout { @@ -22,7 +21,6 @@ flex-direction: column; position: relative; background: linear-gradient(135deg, #fafafa 0%, var(--hi-color-gray) 50%, #faf8fc 100%); - overflow-y: auto; z-index: 2; @include mixins.respond-below(md) { @@ -113,15 +111,26 @@ } } -// Right Panel - Premium visual with background image +// ========================================================= +// RIGHT PANEL — product showcase, matches app's light lavender vibe +// ========================================================= .rightPanel { width: 55%; - max-width: 700px; - position: relative; + max-width: 760px; + position: sticky; + top: 0; + align-self: flex-start; + height: 100vh; + height: 100dvh; overflow: hidden; + isolation: isolate; + background: + radial-gradient(ellipse 80% 60% at 80% 10%, color-mix(in srgb, var(--mantine-color-primary-4) 55%, transparent), transparent 70%), + radial-gradient(ellipse 70% 50% at 15% 90%, color-mix(in srgb, var(--mantine-color-secondary-4) 45%, transparent), transparent 70%), + linear-gradient(180deg, color-mix(in srgb, var(--mantine-color-primary-2) 70%, white) 0%, var(--mantine-color-primary-3) 100%); @include mixins.respond-below(lg) { - width: 45%; + width: 48%; } @include mixins.respond-below(md) { @@ -129,203 +138,543 @@ } } -.backgroundImage { +// Film-grain noise — adds organic texture to the gradient +.noise { position: absolute; inset: 0; - background-image: url("/images/backgrounds/nightlife-bg.jpg"); - background-size: cover; - background-position: center; - filter: grayscale(20%); + pointer-events: none; + z-index: 1; + opacity: 0.22; + mix-blend-mode: multiply; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 160px 160px; } -.backgroundOverlay { - position: absolute; - inset: 0; - background: linear-gradient( - 135deg, - var(--mantine-color-primary-9) 0%, - var(--mantine-color-primary-8) 30%, - var(--mantine-color-primary-6) 60%, - var(--mantine-color-secondary-5) 100% - ); - opacity: 0.92; -} - -// Grid pattern overlay -.gridPattern { +// Subtle dot grid for texture +.dotGrid { position: absolute; inset: 0; - opacity: 0.04; - background-image: - linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px); - background-size: 50px 50px; -} - -// Subtle glow effects -.glowEffect { - position: absolute; - border-radius: 50%; - filter: blur(80px); - opacity: 0.4; + background-image: radial-gradient(circle, color-mix(in srgb, var(--mantine-color-primary-9) 18%, transparent) 1px, transparent 1px); + background-size: 24px 24px; + opacity: 0.35; + mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 30%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 30%, transparent 75%); pointer-events: none; + z-index: 0; } -.glowTop { - top: -100px; - right: -50px; - width: 300px; - height: 300px; - background: rgba(255, 255, 255, 0.15); -} - -.glowBottom { - bottom: -100px; - left: -50px; - width: 350px; - height: 350px; - background: var(--mantine-color-secondary-3); - opacity: 0.2; -} - -.overlay { +// Inner flex column — CENTERED like the form +.panelInner { position: relative; + z-index: 2; height: 100%; display: flex; + flex-direction: column; align-items: center; justify-content: center; - padding: 3rem; - z-index: 1; + padding: 3rem 3rem 5rem; + gap: 2.75rem; + + @include mixins.respond-below(lg) { + padding: 2rem 2rem 4.5rem; + gap: 3rem; + } +} + +// ------- HEADING BLOCK ------- +.headingBlock { + text-align: center; + max-width: 520px; + animation: rise 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +@keyframes rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.heroTitle { + margin: 0; + font-size: 2.75rem; + line-height: 1.02; + letter-spacing: -0.03em; + color: var(--mantine-color-primary-9); @include mixins.respond-below(lg) { - padding: 2rem; + font-size: 2.125rem; } } -.content { - max-width: 400px; +.heroBold { + font-weight: 800; + display: block; +} + +.heroLight { + font-weight: 300; + font-style: italic; + color: color-mix(in srgb, var(--mantine-color-primary-9) 70%, white); + display: block; +} + +// ------- DASHBOARD STAGE — the centerpiece ------- +.dashStage { + position: relative; width: 100%; + max-width: 460px; + aspect-ratio: 1 / 0.82; + animation: rise 1.1s cubic-bezier(0.2, 0.8, 0.2, 1) 0.1s both; @include mixins.respond-below(lg) { - max-width: 340px; + max-width: 380px; } } -// Badge at top -.badge { +// Main event dashboard card +.dashCard { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) rotate(-1.5deg); + width: 100%; + background: white; + border-radius: 18px; + padding: 1.25rem 1.375rem 1.125rem; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.9) inset, + 0 24px 48px -12px color-mix(in srgb, var(--mantine-color-primary-9) 25%, transparent), + 0 2px 8px -2px color-mix(in srgb, var(--mantine-color-primary-9) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--mantine-color-primary-3) 30%, white); + z-index: 2; + animation: floatMain 8s ease-in-out infinite; +} + +@keyframes floatMain { + 0%, 100% { transform: translate(-50%, -50%) rotate(-1.5deg); } + 50% { transform: translate(-50%, calc(-50% - 4px)) rotate(-1.5deg); } +} + +.dashHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.875rem; +} + +.dashHeaderLeft { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.dashCover { + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, var(--mantine-color-primary-5), var(--mantine-color-secondary-5)); + flex-shrink: 0; + position: relative; + overflow: hidden; + + &::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.4), transparent 60%); + } +} + +.dashTitle { + font-size: 0.8125rem; + font-weight: 600; + color: var(--mantine-color-primary-9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +.dashTitleSub { + font-size: 0.6875rem; + color: color-mix(in srgb, var(--mantine-color-primary-9) 50%, white); + margin-top: 1px; +} + +.dashBadge { display: inline-flex; align-items: center; - gap: 0.5rem; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.15); - padding: 0.5rem 1rem; + gap: 0.3125rem; + padding: 0.25rem 0.5rem 0.25rem 0.4375rem; + background: color-mix(in srgb, #16a34a 12%, white); + border: 1px solid color-mix(in srgb, #16a34a 25%, white); border-radius: 9999px; - color: white; - font-size: 0.8125rem; - font-weight: 500; - margin-bottom: 2rem; - backdrop-filter: blur(8px); + font-size: 0.625rem; + font-weight: 600; + color: #15803d; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; +} - @include mixins.respond-below(lg) { - margin-bottom: 1.5rem; - font-size: 0.75rem; - padding: 0.375rem 0.875rem; - } +.dashBadgeDot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #16a34a; + box-shadow: 0 0 6px #16a34a; + animation: pulse 2s ease-in-out infinite; +} - svg { - width: 14px; - height: 14px; - opacity: 0.9; - } +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.7); } } -// Feature grid -.featureGrid { +.dashStatRow { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.125rem; +} + +.dashStatBig { + font-size: 1.875rem; + font-weight: 800; + color: var(--mantine-color-primary-9); + letter-spacing: -0.02em; + line-height: 1; + font-feature-settings: "tnum"; +} + +.dashStatTrend { + display: inline-flex; + align-items: center; + gap: 0.125rem; + font-size: 0.75rem; + font-weight: 600; + color: #15803d; +} + +.dashStatLabel { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: color-mix(in srgb, var(--mantine-color-primary-9) 45%, white); + margin-bottom: 0.75rem; +} + +.dashChart { + width: 100%; + height: 48px; + margin-bottom: 0.875rem; + overflow: visible; +} + +.dashChartLine { + fill: none; + stroke: var(--mantine-color-primary-6); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 500; + stroke-dashoffset: 500; + animation: drawLine 2s cubic-bezier(0.4, 0, 0.2, 1) 0.4s forwards; +} + +@keyframes drawLine { + to { stroke-dashoffset: 0; } +} + +.dashChartFill { + fill: url(#chartGradient); + opacity: 0; + animation: fadeIn 0.8s ease-out 1.2s forwards; +} + +@keyframes fadeIn { + to { opacity: 1; } +} + +.dashChartDot { + fill: var(--mantine-color-primary-6); + stroke: white; + stroke-width: 2; + opacity: 0; + animation: fadeIn 0.4s ease-out 2s forwards; +} + +.dashTiers { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.4375rem; + margin-bottom: 0.875rem; +} - @include mixins.respond-below(lg) { - gap: 0.5rem; +.dashTier { + display: grid; + grid-template-columns: 64px 1fr 42px; + align-items: center; + gap: 0.625rem; + font-size: 0.6875rem; +} + +.dashTierName { + color: var(--mantine-color-primary-9); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashTierBar { + height: 5px; + background: color-mix(in srgb, var(--mantine-color-primary-2) 60%, white); + border-radius: 999px; + overflow: hidden; + position: relative; +} + +.dashTierBarFill { + position: absolute; + inset: 0; + background: linear-gradient(90deg, var(--mantine-color-primary-5), var(--mantine-color-primary-7)); + border-radius: 999px; + transform-origin: left; + transform: scaleX(0); + animation: fillBar 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@keyframes fillBar { + to { transform: scaleX(var(--fill, 0.5)); } +} + +.dashTierCount { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.625rem; + color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white); + text-align: right; + font-feature-settings: "tnum"; +} + +.dashFooter { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 0.75rem; + border-top: 1px solid color-mix(in srgb, var(--mantine-color-primary-2) 60%, white); +} + +.dashAvatars { + display: flex; + align-items: center; +} + +.dashAvatar { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid white; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.5625rem; + font-weight: 700; + color: white; + + &:not(:first-child) { + margin-left: -7px; } } -.feature { +.dashAvatar1 { background: linear-gradient(135deg, #f97316, #dc2626); } +.dashAvatar2 { background: linear-gradient(135deg, #8b5cf6, #6366f1); } +.dashAvatar3 { background: linear-gradient(135deg, #06b6d4, #0ea5e9); } +.dashAvatar4 { background: linear-gradient(135deg, #10b981, #059669); } + +.dashFooterText { + font-size: 0.6875rem; + color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white); + font-weight: 500; +} + +// Floating secondary notification cards +.floatCard { + position: absolute; + background: white; + border-radius: 14px; + padding: 0.75rem 0.875rem; + box-shadow: + 0 18px 36px -12px color-mix(in srgb, var(--mantine-color-primary-9) 22%, transparent), + 0 2px 6px -1px color-mix(in srgb, var(--mantine-color-primary-9) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--mantine-color-primary-3) 25%, white); display: flex; - align-items: flex-start; - gap: 1rem; - padding: 1rem 1.25rem; - border-radius: 1rem; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - backdrop-filter: blur(8px); - transition: all 0.3s ease; - cursor: default; + align-items: center; + gap: 0.625rem; + min-width: 0; + z-index: 3; +} + +.floatCardTop { + top: 4%; + right: -6%; + width: 200px; + transform: rotate(3deg); + animation: floatA 7s ease-in-out infinite; @include mixins.respond-below(lg) { - padding: 0.875rem 1rem; - gap: 0.75rem; + width: 180px; + top: 6%; + right: -4%; } +} + +.floatCardBottom { + bottom: -9%; + left: -8%; + width: 210px; + transform: rotate(-4deg); + animation: floatB 9s ease-in-out infinite; - &:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.15); - transform: translateX(4px); + @include mixins.respond-below(lg) { + width: 190px; + bottom: -11%; + left: -6%; } } -.featureIcon { +@keyframes floatA { + 0%, 100% { transform: rotate(3deg) translateY(0); } + 50% { transform: rotate(3deg) translateY(-6px); } +} + +@keyframes floatB { + 0%, 100% { transform: rotate(-4deg) translateY(0); } + 50% { transform: rotate(-4deg) translateY(-6px); } +} + +.floatIcon { + width: 32px; + height: 32px; + border-radius: 9px; display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; - min-width: 36px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.12); - color: white; + flex-shrink: 0; + background: color-mix(in srgb, var(--mantine-color-primary-3) 25%, white); + color: var(--mantine-color-primary-7); +} + +.floatBody { + min-width: 0; + flex: 1; +} + +.floatTitle { + font-size: 0.6875rem; + font-weight: 600; + color: var(--mantine-color-primary-9); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.floatSub { + font-size: 0.625rem; + color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ------- FEATURE TICKER — pinned at bottom, subtle scroll ------- +.ticker { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3.25rem; + display: flex; + align-items: center; + overflow: hidden; + z-index: 4; + border-top: 1px solid color-mix(in srgb, var(--mantine-color-primary-9) 8%, transparent); + background: color-mix(in srgb, var(--mantine-color-primary-0) 55%, transparent); + backdrop-filter: blur(10px) saturate(140%); + -webkit-backdrop-filter: blur(10px) saturate(140%); + mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent); + -webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent); @include mixins.respond-below(lg) { - width: 32px; - height: 32px; - min-width: 32px; + height: 3rem; } +} - svg { - width: 18px; - height: 18px; +.tickerTrack { + display: flex; + align-items: center; + gap: 2.25rem; + width: max-content; + animation: tickerScroll 180s linear infinite; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: color-mix(in srgb, var(--mantine-color-primary-9) 60%, white); + white-space: nowrap; + will-change: transform; - @include mixins.respond-below(lg) { - width: 16px; - height: 16px; - } + @include mixins.respond-below(lg) { + font-size: 0.625rem; + gap: 1.875rem; } } -.featureText { - flex: 1; - min-width: 0; +@keyframes tickerScroll { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} - h3 { - margin: 0 0 0.25rem; - font-size: 0.9375rem; - font-weight: 600; - color: white; - letter-spacing: -0.01em; +.tickerItem { + display: inline-flex; + align-items: center; + gap: 2.25rem; - @include mixins.respond-below(lg) { - font-size: 0.875rem; - } + @include mixins.respond-below(lg) { + gap: 1.875rem; } +} - p { - margin: 0; - font-size: 0.8125rem; - color: rgba(255, 255, 255, 0.7); - line-height: 1.5; +.tickerDot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--mantine-color-primary-5); + opacity: 0.55; + flex-shrink: 0; +} - @include mixins.respond-below(lg) { - font-size: 0.75rem; - } +@media (prefers-reduced-motion: reduce) { + .headingBlock, + .dashStage, + .dashCard, + .dashBadgeDot, + .dashChartLine, + .dashChartFill, + .dashChartDot, + .dashTierBarFill, + .floatCardTop, + .floatCardBottom, + .tickerTrack { + animation: none; } + + .dashChartLine { stroke-dashoffset: 0; } + .dashChartFill, + .dashChartDot { opacity: 1; } + .dashTierBarFill { transform: scaleX(var(--fill, 0.5)); } } diff --git a/frontend/src/components/layouts/AuthLayout/index.tsx b/frontend/src/components/layouts/AuthLayout/index.tsx index 88ca89099f..6a88fb0627 100644 --- a/frontend/src/components/layouts/AuthLayout/index.tsx +++ b/frontend/src/components/layouts/AuthLayout/index.tsx @@ -4,102 +4,152 @@ import {t} from "@lingui/macro"; import {useGetMe} from "../../../queries/useGetMe.ts"; import {PoweredByFooter} from "../../common/PoweredByFooter"; import {LanguageSwitcher} from "../../common/LanguageSwitcher"; -import { - IconChartBar, - IconCreditCard, - IconDeviceMobile, - IconPalette, - IconQrcode, - IconShieldCheck, - IconSparkles, - IconTicket, - IconUsers, -} from '@tabler/icons-react'; -import {useCallback, useMemo, useRef} from "react"; +import {IconBellRinging, IconUsersGroup} from "@tabler/icons-react"; +import {useCallback, useRef} from "react"; import {getConfig} from "../../../utilites/config.ts"; import {isHiEvents} from "../../../utilites/helpers.ts"; import {showInfo} from "../../../utilites/notifications.tsx"; -const allFeatures = [ - { - icon: IconTicket, - title: t`Flexible Ticketing`, - description: t`Paid, free, tiered pricing, and donation-based tickets` - }, - { - icon: IconQrcode, - title: t`QR Code Check-in`, - description: t`Mobile scanner with offline support and real-time tracking` - }, - { - icon: IconCreditCard, - title: t`Instant Payouts`, - description: t`Get paid immediately via Stripe Connect` - }, - { - icon: IconChartBar, - title: t`Real-Time Analytics`, - description: t`Track sales, revenue, and attendance with detailed reports` - }, - { - icon: IconPalette, - title: t`Custom Branding`, - description: t`Your logo, colors, and style on every page` - }, - { - icon: IconDeviceMobile, - title: t`Mobile Optimized`, - description: t`Beautiful checkout experience on any device` - }, - { - icon: IconUsers, - title: t`Team Management`, - description: t`Invite unlimited team members with custom roles` - }, - { - icon: IconShieldCheck, - title: t`Data Ownership`, - description: t`You own 100% of your attendee data, always` - }, +const tiers = [ + {name: "VIP Pass", count: "87/100", fill: 0.87}, + {name: "Early Bird", count: "240/240", fill: 1.0}, + {name: "General", count: "512/750", fill: 0.68}, +]; + +const tickerFeatures = [ + t`Recurring events`, + t`Instant Stripe payouts`, + t`Custom branding`, + t`QR code check-in`, + t`Waitlist`, + t`Promo codes`, + t`Real-time analytics`, + t`Email & scheduled messages`, + t`Embeddable widget`, + t`Affiliate program`, + t`Team collaboration`, + t`Custom questions`, + t`Webhook integrations`, + t`Full data ownership`, + t`Multiple ticket types`, + t`Capacity management`, ]; const FeaturePanel = () => { - const selectedFeatures = useMemo(() => { - const shuffled = [...allFeatures].sort(() => 0.5 - Math.random()); - return shuffled.slice(0, 4); - }, []); + const tickerLoop = [...tickerFeatures, ...tickerFeatures]; return (
-
-
-
-
-
- -
-
-
- - {t`Event Management Platform`} +
+
+ +
+
+

+ {t`Sell out your event.`} + {t`Keep the profit.`} +

+
+ +