Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions backend/.env.testing
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A
final public const DELETED_AT = 'deleted_at';
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
final public const PUBLIC_SHOW_ATTENDEE_NOTES = 'public_show_attendee_notes';
final public const PUBLIC_SHOW_QUESTION_ANSWERS = 'public_show_question_answers';
final public const PUBLIC_SHOW_ORDER_DETAILS = 'public_show_order_details';

protected int $id;
protected int $event_id;
Expand All @@ -31,6 +34,9 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A
protected ?string $deleted_at = null;
protected ?string $created_at = null;
protected ?string $updated_at = null;
protected bool $public_show_attendee_notes = true;
protected bool $public_show_question_answers = true;
protected bool $public_show_order_details = true;

public function toArray(): array
{
Expand All @@ -45,6 +51,9 @@ public function toArray(): array
'deleted_at' => $this->deleted_at ?? null,
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
'public_show_attendee_notes' => $this->public_show_attendee_notes ?? null,
'public_show_question_answers' => $this->public_show_question_answers ?? null,
'public_show_order_details' => $this->public_show_order_details ?? null,
];
}

Expand Down Expand Up @@ -157,4 +166,37 @@ public function getUpdatedAt(): ?string
{
return $this->updated_at;
}

public function setPublicShowAttendeeNotes(bool $public_show_attendee_notes): self
{
$this->public_show_attendee_notes = $public_show_attendee_notes;
return $this;
}

public function getPublicShowAttendeeNotes(): bool
{
return $this->public_show_attendee_notes;
}

public function setPublicShowQuestionAnswers(bool $public_show_question_answers): self
{
$this->public_show_question_answers = $public_show_question_answers;
return $this;
}

public function getPublicShowQuestionAnswers(): bool
{
return $this->public_show_question_answers;
}

public function setPublicShowOrderDetails(bool $public_show_order_details): self
{
$this->public_show_order_details = $public_show_order_details;
return $this;
}

public function getPublicShowOrderDetails(): bool
{
return $this->public_show_order_details;
}
}
22 changes: 11 additions & 11 deletions backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
final public const ID = 'id';
final public const USER_ID = 'user_id';
final public const EVENT_ID = 'event_id';
final public const ORGANIZER_ID = 'organizer_id';
final public const ACCOUNT_ID = 'account_id';
final public const ORGANIZER_ID = 'organizer_id';
final public const URL = 'url';
final public const EVENT_TYPES = 'event_types';
final public const LAST_RESPONSE_CODE = 'last_response_code';
Expand All @@ -29,8 +29,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
protected int $id;
protected int $user_id;
protected ?int $event_id = null;
protected ?int $organizer_id = null;
protected int $account_id;
protected ?int $organizer_id = null;
protected string $url;
protected array|string $event_types;
protected ?int $last_response_code = null;
Expand All @@ -48,8 +48,8 @@ public function toArray(): array
'id' => $this->id ?? null,
'user_id' => $this->user_id ?? null,
'event_id' => $this->event_id ?? null,
'organizer_id' => $this->organizer_id ?? null,
'account_id' => $this->account_id ?? null,
'organizer_id' => $this->organizer_id ?? null,
'url' => $this->url ?? null,
'event_types' => $this->event_types ?? null,
'last_response_code' => $this->last_response_code ?? null,
Expand Down Expand Up @@ -96,26 +96,26 @@ public function getEventId(): ?int
return $this->event_id;
}

public function setOrganizerId(?int $organizer_id): self
public function setAccountId(int $account_id): self
{
$this->organizer_id = $organizer_id;
$this->account_id = $account_id;
return $this;
}

public function getOrganizerId(): ?int
public function getAccountId(): int
{
return $this->organizer_id;
return $this->account_id;
}

public function setAccountId(int $account_id): self
public function setOrganizerId(?int $organizer_id): self
{
$this->account_id = $account_id;
$this->organizer_id = $organizer_id;
return $this;
}

public function getAccountId(): int
public function getOrganizerId(): ?int
{
return $this->account_id;
return $this->organizer_id;
}

public function setUrl(string $url): self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId): JsonR
productIds: $request->validated('product_ids'),
expiresAt: $request->validated('expires_at'),
activatesAt: $request->validated('activates_at'),
publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true,
publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true,
publicShowOrderDetails: $request->validated('public_show_order_details') ?? true,
)
);
} catch (UnrecognizedProductIdException $exception) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace HiEvents\Http\Actions\CheckInLists\Public;

use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\Attendee\AttendeeDetailPublicResource;
use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListAttendeeDetailPublicHandler;
use HiEvents\Services\Domain\Auth\AuthUserService;
use Illuminate\Http\JsonResponse;
use Throwable;

class GetCheckInListAttendeeDetailPublicAction extends BaseAction
{
public function __construct(
private readonly GetCheckInListAttendeeDetailPublicHandler $handler,
private readonly AuthUserService $authUserService,
)
{
}

public function __invoke(string $checkInListShortId, string $attendeePublicId): JsonResponse
{
$detail = $this->handler->handle(
shortId: $checkInListShortId,
attendeePublicId: $attendeePublicId,
staffAccountId: $this->resolveStaffAccountId(),
);

return $this->resourceResponse(
resource: AttendeeDetailPublicResource::class,
data: $detail,
);
}

/**
* The detail endpoint is public but should reveal all attendee fields to authenticated staff
* whose account matches the event's account. Returns null for anonymous / invalid tokens /
* any auth resolution failure — those callers get data filtered by the list's visibility flags.
*/
private function resolveStaffAccountId(): ?int
{
try {
return $this->authUserService->getAuthenticatedAccountId();
} catch (Throwable) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace HiEvents\Http\Actions\CheckInLists\Public;

use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\CheckInList\CheckInListStatsPublicResource;
use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListStatsPublicHandler;
use Illuminate\Http\JsonResponse;

class GetCheckInListStatsPublicAction extends BaseAction
{
public function __construct(
private readonly GetCheckInListStatsPublicHandler $getCheckInListStatsPublicHandler,
)
{
}

public function __invoke(string $checkInListShortId): JsonResponse
{
$stats = $this->getCheckInListStatsPublicHandler->handle($checkInListShortId);

return $this->resourceResponse(
resource: CheckInListStatsPublicResource::class,
data: $stats,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId, int $c
expiresAt: $request->validated('expires_at'),
activatesAt: $request->validated('activates_at'),
id: $checkInListId,
publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true,
publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true,
publicShowOrderDetails: $request->validated('public_show_order_details') ?? true,
)
);
} catch (UnrecognizedProductIdException $exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ public function rules(): array
{
return [
'name' => RulesHelper::REQUIRED_STRING,
'description' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'expires_at' => ['nullable', 'date'],
'activates_at' => ['nullable', 'date'],
'product_ids' => ['required', 'array', 'min:1'],
'public_show_attendee_notes' => ['nullable', 'boolean'],
'public_show_question_answers' => ['nullable', 'boolean'],
'public_show_order_details' => ['nullable', 'boolean'],
];
}

Expand Down
17 changes: 17 additions & 0 deletions backend/app/Repository/DTO/CheckInListProductStatDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace HiEvents\Repository\DTO;

use HiEvents\DataTransferObjects\BaseDTO;

class CheckInListProductStatDTO extends BaseDTO
{
public function __construct(
public int $productId,
public string $productTitle,
public int $totalAttendees,
public int $checkedInAttendees,
)
{
}
}
Loading
Loading