mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-16 17:53:18 +01:00
Compare commits
1 Commits
1ab271b38a
...
47327aa52b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47327aa52b |
1
.env.ci
1
.env.ci
@@ -6,7 +6,6 @@ APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SUPER_ADMINS=admin@example.com
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
4
.github/workflows/build-onpremise.yml
vendored
4
.github/workflows/build-onpremise.yml
vendored
@@ -141,7 +141,7 @@ jobs:
|
||||
${{ env.DOCKER_REPO }}
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
|
||||
4
.github/workflows/build-private.yml
vendored
4
.github/workflows/build-private.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: "Install dependencies in billing extension"
|
||||
uses: php-actions/composer@v6
|
||||
env:
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
with:
|
||||
working_dir: "extensions/Billing"
|
||||
command: install
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: rg.fr-par.scw.cloud/solidtime
|
||||
username: nologin
|
||||
|
||||
8
.github/workflows/build-public.yml
vendored
8
.github/workflows/build-public.yml
vendored
@@ -117,13 +117,13 @@ jobs:
|
||||
${{ env.GHCR_REPO }}
|
||||
|
||||
- name: "Login to Docker Hub Container Registry"
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -177,13 +177,13 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to Docker Hub"
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GHCR"
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
31
.github/workflows/extension-manifest-check.yml
vendored
31
.github/workflows/extension-manifest-check.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Extension Manifest Check
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- extensions/manifest.json
|
||||
- .github/workflows/extension-manifest-check.yml
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check-refs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Extension refs must be pinned to a release tag (vX.Y.Z) or a full commit
|
||||
# SHA. Branch names (main, feature/foo, ...) are only acceptable while
|
||||
# developing and must be replaced before the pull request is merged.
|
||||
- name: "Check extension refs are pinned"
|
||||
run: |
|
||||
failed=0
|
||||
while IFS=$'\t' read -r name ref; do
|
||||
if [[ "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$ ]] || [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "✅ $name: $ref"
|
||||
else
|
||||
echo "::error file=extensions/manifest.json::Extension \"$name\" uses ref \"$ref\", which is not a release tag or commit SHA. Pin it before merging."
|
||||
failed=1
|
||||
fi
|
||||
done < <(jq -r 'to_entries[] | [.key, .value.ref] | @tsv' extensions/manifest.json)
|
||||
exit $failed
|
||||
136
.github/workflows/phpunit-extensions.yml
vendored
136
.github/workflows/phpunit-extensions.yml
vendored
@@ -1,136 +0,0 @@
|
||||
name: PHPUnit Tests - Extensions
|
||||
on: push
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
phpunit-extensions:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
matrix:
|
||||
postgres_version: [ 15, 16, 17 ]
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
image: postgres:${{ matrix.postgres_version }}
|
||||
env:
|
||||
PGPASSWORD: 'root'
|
||||
POSTGRES_DB: 'laravel'
|
||||
POSTGRES_USER: 'root'
|
||||
POSTGRES_PASSWORD: 'root'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
ports:
|
||||
- 3000:3000
|
||||
options: >-
|
||||
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: pcov
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Read extension manifest"
|
||||
id: extension-manifest
|
||||
run: |
|
||||
{
|
||||
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
|
||||
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
|
||||
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
|
||||
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Checkout billing extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
||||
path: extensions/Billing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in billing extension"
|
||||
working-directory: extensions/Billing
|
||||
env:
|
||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in billing extension"
|
||||
run: cd extensions/Billing && npm ci
|
||||
|
||||
- name: "Checkout services extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
||||
path: extensions/Services
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in services extension"
|
||||
working-directory: extensions/Services
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in services extension"
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in invoicing extension"
|
||||
working-directory: extensions/Invoicing
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Install npm dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && npm ci
|
||||
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||
|
||||
- name: "Activate billing extension"
|
||||
run: php artisan module:enable Billing
|
||||
|
||||
- name: "Activate services extension"
|
||||
run: php artisan module:enable Services
|
||||
|
||||
- name: "Activate invoicing extension"
|
||||
run: php artisan module:enable Invoicing
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Build Frontend"
|
||||
run: npm run build
|
||||
|
||||
- name: "Prepare Laravel Application"
|
||||
run: |
|
||||
cp .env.ci .env
|
||||
php artisan key:generate
|
||||
php artisan passport:keys
|
||||
|
||||
- name: "Run PHPUnit"
|
||||
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v7.1.1
|
||||
uses: codecov/codecov-action@v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,7 +6,6 @@ dist
|
||||
/public/storage
|
||||
/public/css
|
||||
/public/js
|
||||
/public/fonts/filament
|
||||
/public/vendor
|
||||
/lang/vendor
|
||||
/storage/*.key
|
||||
@@ -44,5 +43,4 @@ yarn-error.log
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
/config/psysh
|
||||
/AGENTS.md
|
||||
|
||||
@@ -40,7 +40,6 @@ use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use League\Flysystem\UnableToReadFile;
|
||||
|
||||
class OrganizationResource extends Resource
|
||||
{
|
||||
@@ -182,13 +181,9 @@ class OrganizationResource extends Resource
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data): void {
|
||||
try {
|
||||
try {
|
||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||
} catch (UnableToReadFile) {
|
||||
$file = null;
|
||||
}
|
||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||
if ($file === null) {
|
||||
throw new ImportException('File not found');
|
||||
throw new Exception('File not found');
|
||||
}
|
||||
/** @var string $timezone */
|
||||
$timezone = $data['timezone'];
|
||||
|
||||
@@ -45,7 +45,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
'is_billable' => ($record['Billability'] ?? '') === 'Yes',
|
||||
'is_billable' => $record['Billability'] === 'Yes',
|
||||
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
||||
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
||||
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
||||
@@ -55,7 +55,6 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
||||
$tasks = explode(', ', $record[$tasksKey]);
|
||||
foreach ($tasks as $task) {
|
||||
$this->checkTaskNameLength($task);
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task,
|
||||
'project_id' => $projectId,
|
||||
@@ -85,6 +84,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
'Project',
|
||||
'Status',
|
||||
'Visibility',
|
||||
'Billability',
|
||||
];
|
||||
foreach ($requiredFields as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
|
||||
@@ -104,7 +104,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
}
|
||||
$taskId = null;
|
||||
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
|
||||
$this->checkTaskNameLength($record[$taskKey]);
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record[$taskKey],
|
||||
'project_id' => $projectId,
|
||||
|
||||
@@ -22,8 +22,6 @@ use Override;
|
||||
|
||||
abstract class DefaultImporter implements ImporterContract
|
||||
{
|
||||
protected const TASK_NAME_MAX_LENGTH = 500;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
/**
|
||||
@@ -184,16 +182,6 @@ abstract class DefaultImporter implements ImporterContract
|
||||
$this->billableRateService = app(BillableRateService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
protected function checkTaskNameLength(string $taskName): void
|
||||
{
|
||||
if (strlen($taskName) > self::TASK_NAME_MAX_LENGTH) {
|
||||
throw new ImportException('Task name ("'.$taskName.'") is too long, maximum length is '.self::TASK_NAME_MAX_LENGTH.' characters');
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getReport(): ReportDto
|
||||
{
|
||||
|
||||
@@ -114,7 +114,6 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['task'] !== '') {
|
||||
$this->checkTaskNameLength($record['task']);
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['task'],
|
||||
'project_id' => $projectId,
|
||||
|
||||
@@ -92,7 +92,6 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['Task'] !== '') {
|
||||
$this->checkTaskNameLength($record['Task']);
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['Task'],
|
||||
'project_id' => $projectId,
|
||||
|
||||
@@ -199,7 +199,6 @@ class SolidtimeImporter extends DefaultImporter
|
||||
if ($projectId === null) {
|
||||
throw new Exception('Project does not exist');
|
||||
}
|
||||
$this->checkTaskNameLength($task['name']);
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task['name'],
|
||||
'project_id' => $projectId,
|
||||
|
||||
@@ -153,16 +153,9 @@ class TogglDataImporter extends DefaultImporter
|
||||
}
|
||||
foreach ($projectMembers as $projectMember) {
|
||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
||||
if ($userId === null) {
|
||||
throw new Exception('User does not exist');
|
||||
}
|
||||
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($userId);
|
||||
if ($memberId === null) {
|
||||
throw new Exception('Member does not exist');
|
||||
}
|
||||
$this->projectMemberImportHelper->getKey([
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
|
||||
@@ -189,7 +182,6 @@ class TogglDataImporter extends DefaultImporter
|
||||
if ($projectId === null) {
|
||||
throw new Exception('Project does not exist');
|
||||
}
|
||||
$this->checkTaskNameLength($task->name);
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task->name,
|
||||
'project_id' => $projectId,
|
||||
|
||||
@@ -98,7 +98,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['Task'] !== '') {
|
||||
$this->checkTaskNameLength($record['Task']);
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['Task'],
|
||||
'project_id' => $projectId,
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
|
||||
'ssr' => [
|
||||
|
||||
'enabled' => (bool) env('INERTIA_SSR_ENABLED', false),
|
||||
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
|
||||
|
||||
'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
ARG PHP_VERSION=8.3
|
||||
ARG FRANKENPHP_VERSION=1.11
|
||||
ARG FRANKENPHP_VERSION=1.8
|
||||
ARG COMPOSER_VERSION=2.8
|
||||
ARG BUN_VERSION="latest"
|
||||
ARG APP_ENV
|
||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||
|
||||
@@ -15,13 +16,13 @@ RUN CGO_ENABLED=1 \
|
||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||
CGO_CFLAGS=$(php-config --includes) \
|
||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||
xcaddy build \
|
||||
xcaddy build v2.10.0 \
|
||||
--output /usr/local/bin/frankenphp \
|
||||
--with github.com/dunglas/frankenphp=./ \
|
||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||
--with github.com/dunglas/caddy-cbrotli
|
||||
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
|
||||
|
||||
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
||||
|
||||
@@ -31,28 +32,26 @@ LABEL org.opencontainers.image.description="solidtime is a modern open source ti
|
||||
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
||||
LABEL org.opencontainers.image.licenses="AGPL"
|
||||
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
ARG WWWUSER=1000
|
||||
ARG WWWGROUP=1000
|
||||
ARG TZ=UTC
|
||||
ARG APP_DIR=/var/www/html
|
||||
ARG APP_ENV
|
||||
ARG APP_HOST
|
||||
ARG DOCKER_FILES_BASE_PATH
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TERM=xterm-color \
|
||||
OCTANE_SERVER=frankenphp \
|
||||
TZ=${TZ} \
|
||||
LANG=C.UTF-8 \
|
||||
USER=laravel \
|
||||
ROOT=/var/www/html \
|
||||
APP_ENV=production \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||
USER=octane \
|
||||
ROOT=${APP_DIR} \
|
||||
APP_ENV=${APP_ENV} \
|
||||
COMPOSER_FUND=0 \
|
||||
COMPOSER_MAX_PARALLEL_HTTP=48 \
|
||||
WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
WITH_REVERB=false \
|
||||
WITH_SSR=false
|
||||
|
||||
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
|
||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
||||
SERVER_NAME=${APP_HOST}
|
||||
|
||||
WORKDIR ${ROOT}
|
||||
|
||||
@@ -61,9 +60,6 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
|
||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||
&& echo ${TZ} > /etc/timezone
|
||||
|
||||
RUN echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
||||
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
||||
|
||||
RUN apt-get update; \
|
||||
apt-get upgrade -yqq; \
|
||||
apt-get install -yqq --no-install-recommends --show-progress \
|
||||
@@ -72,36 +68,40 @@ RUN apt-get update; \
|
||||
wget \
|
||||
vim \
|
||||
git \
|
||||
unzip \
|
||||
ncdu \
|
||||
procps \
|
||||
unzip \
|
||||
ca-certificates \
|
||||
supervisor \
|
||||
libsodium-dev \
|
||||
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
|
||||
libbrotli-dev \
|
||||
# Install PHP extensions (included with dunglas/frankenphp)
|
||||
&& install-php-extensions \
|
||||
apcu \
|
||||
bz2 \
|
||||
pcntl \
|
||||
mbstring \
|
||||
bcmath \
|
||||
sockets \
|
||||
pgsql \
|
||||
pdo_pgsql \
|
||||
opcache \
|
||||
exif \
|
||||
pdo_mysql \
|
||||
zip \
|
||||
uv \
|
||||
vips \
|
||||
intl \
|
||||
gd \
|
||||
redis \
|
||||
rdkafka \
|
||||
ffi \
|
||||
memcached \
|
||||
igbinary \
|
||||
ldap \
|
||||
&& apt-get -y autoremove \
|
||||
&& apt-get clean \
|
||||
&& docker-php-source delete \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||
&& rm /var/log/lastlog /var/log/faillog
|
||||
|
||||
RUN arch="$(uname -m)" \
|
||||
&& case "$arch" in \
|
||||
@@ -111,64 +111,106 @@ RUN arch="$(uname -m)" \
|
||||
x86) _cronic_fname='supercronic-linux-386' ;; \
|
||||
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
||||
esac \
|
||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \
|
||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
|
||||
-O /usr/bin/supercronic \
|
||||
&& chmod +x /usr/bin/supercronic \
|
||||
&& mkdir -p /etc/supercronic \
|
||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
||||
|
||||
RUN userdel --remove --force www-data \
|
||||
&& groupadd --force -g ${GROUP_ID} ${USER} \
|
||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER}
|
||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
|
||||
&& setcap -r /usr/local/bin/frankenphp
|
||||
|
||||
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
|
||||
&& chmod -R a+rw ${ROOT} /var/{log,run}
|
||||
|
||||
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
||||
|
||||
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
||||
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-php.ini
|
||||
#COPY --link composer.* ./
|
||||
USER ${USER}
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
|
||||
|
||||
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||
|
||||
###########################################
|
||||
|
||||
#FROM base AS common
|
||||
#
|
||||
#USER ${USER}
|
||||
#
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||
#
|
||||
#RUN composer install \
|
||||
# --no-dev \
|
||||
# --no-interaction \
|
||||
# --no-autoloader \
|
||||
# --no-ansi \
|
||||
# --no-scripts \
|
||||
# --no-progress \
|
||||
# --audit
|
||||
|
||||
###########################################
|
||||
# Build frontend assets with Bun
|
||||
###########################################
|
||||
|
||||
#FROM oven/bun:${BUN_VERSION} AS build
|
||||
#
|
||||
#ARG APP_ENV
|
||||
#
|
||||
#ENV ROOT=/var/www/html \
|
||||
# APP_ENV=${APP_ENV} \
|
||||
# NODE_ENV=${APP_ENV:-production}
|
||||
#
|
||||
#WORKDIR ${ROOT}
|
||||
#
|
||||
#COPY --link package.json bun.lock* ./
|
||||
|
||||
#
|
||||
#RUN bun install --frozen-lockfile
|
||||
#
|
||||
#COPY --link . .
|
||||
#COPY --link --from=common ${ROOT}/vendor vendor
|
||||
#
|
||||
#RUN bun run build
|
||||
|
||||
COPY --link . .
|
||||
###########################################
|
||||
|
||||
#FROM common AS runner
|
||||
|
||||
USER ${USER}
|
||||
|
||||
ENV WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
WITH_REVERB=false
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
||||
|
||||
RUN test -z "$(find . -name .git -print -quit)"
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||
|
||||
RUN mkdir -p \
|
||||
storage/framework/{sessions,views,cache,testing} \
|
||||
storage/logs \
|
||||
bootstrap/cache \
|
||||
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||
bootstrap/cache && chmod -R a+rw storage
|
||||
|
||||
RUN composer dump-autoload \
|
||||
--optimize \
|
||||
--apcu \
|
||||
--no-dev
|
||||
#RUN composer install \
|
||||
# --classmap-authoritative \
|
||||
# --no-interaction \
|
||||
# --no-ansi \
|
||||
# --no-dev \
|
||||
# && composer clear-cache
|
||||
|
||||
#RUN bun run build
|
||||
|
||||
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
|
||||
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
|
||||
|
||||
USER ${USER}
|
||||
RUN cat .env
|
||||
#RUN php artisan env
|
||||
|
||||
EXPOSE 8000
|
||||
#EXPOSE 2019
|
||||
#EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["start-container"]
|
||||
|
||||
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1
|
||||
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
||||
|
||||
@@ -22,13 +22,6 @@ elif [ "${container_mode}" = "reverb" ]; then
|
||||
echo "Healthcheck failed."
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${container_mode}" = "ssr" ]; then
|
||||
if [ "$(supervisorctl status inertia-ssr-server:inertia-ssr-server_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||
exit 0
|
||||
else
|
||||
echo "Healthcheck failed."
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${container_mode}" = "worker" ]; then
|
||||
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||
exit 0
|
||||
|
||||
@@ -3,14 +3,6 @@
|
||||
|
||||
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
||||
|
||||
log {
|
||||
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||
}
|
||||
|
||||
auto_https off
|
||||
|
||||
skip_install_trust
|
||||
|
||||
frankenphp {
|
||||
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
||||
}
|
||||
@@ -28,7 +20,7 @@
|
||||
|
||||
{$CADDY_SERVER_SERVER_NAME} {
|
||||
log {
|
||||
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||
level WARN
|
||||
|
||||
format filter {
|
||||
wrap {$CADDY_SERVER_LOGGER}
|
||||
@@ -68,6 +60,7 @@
|
||||
error @rejected 401
|
||||
|
||||
php_server {
|
||||
index frankenphp-worker.php
|
||||
try_files {path} frankenphp-worker.php
|
||||
resolve_root_symlink
|
||||
}
|
||||
|
||||
@@ -1,18 +1,65 @@
|
||||
[program:octane]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-host=0.0.0.0 --admin-port=2019 --log-level=WARN --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||
user = %(ENV_USER)s
|
||||
priority = 1
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopwaitsecs = 30
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
environment = LARAVEL_OCTANE = "1"
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[program:horizon]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
priority = 3
|
||||
autostart = %(ENV_WITH_HORIZON)s
|
||||
autorestart = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
stopwaitsecs = 3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = false
|
||||
startsecs = 0
|
||||
startretries = 1
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:reverb]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
priority = 2
|
||||
autostart = %(ENV_WITH_REVERB)s
|
||||
autorestart = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
minfds = 10000
|
||||
|
||||
[include]
|
||||
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf
|
||||
files = /etc/supervisord.conf
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
post_max_size = 100M
|
||||
upload_max_filesize = 100M
|
||||
expose_php = 0
|
||||
realpath_cache_size = 32M
|
||||
realpath_cache_ttl = 720
|
||||
memory_limit = 256M
|
||||
realpath_cache_size = 16M
|
||||
realpath_cache_ttl = 360
|
||||
max_input_time = 5
|
||||
register_argc_argv = 0
|
||||
date.timezone = ${TZ:-UTC}
|
||||
@@ -12,24 +11,18 @@ date.timezone = ${TZ:-UTC}
|
||||
[Opcache]
|
||||
opcache.enable = 1
|
||||
opcache.enable_cli = 1
|
||||
opcache.memory_consumption = 256
|
||||
opcache.memory_consumption = 256M
|
||||
opcache.use_cwd = 0
|
||||
opcache.save_comments = 1
|
||||
opcache.max_file_size = 0
|
||||
opcache.max_accelerated_files = 32531
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.file_update_protection = 0
|
||||
opcache.interned_strings_buffer = 16
|
||||
opcache.enable_file_override = 1
|
||||
opcache.file_cache_consistency_checks = 0
|
||||
opcache.file_cache = /tmp/opcache-file-cache
|
||||
|
||||
[JIT]
|
||||
opcache.jit_buffer_size = 128M
|
||||
opcache.jit = tracing
|
||||
opcache.jit_hot_loop = 16
|
||||
opcache.jit_hot_func = 32
|
||||
opcache.jit_hot_return = 4
|
||||
opcache.jit = function
|
||||
opcache.jit_prof_threshold = 0.001
|
||||
opcache.jit_max_root_traces = 2048
|
||||
opcache.jit_max_side_traces = 256
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[supervisord]
|
||||
nodaemon = true
|
||||
user = %(ENV_USER)s
|
||||
logfile = /var/log/supervisor/supervisord.log
|
||||
pidfile = /var/run/supervisord.pid
|
||||
|
||||
[supervisorctl]
|
||||
|
||||
@@ -8,4 +10,4 @@ user = %(ENV_USER)s
|
||||
port = 127.0.0.1:9001
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
@@ -4,8 +4,6 @@ command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[program:inertia-ssr-server]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[include]
|
||||
files = /etc/supervisord.conf
|
||||
@@ -4,8 +4,6 @@ command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
@@ -4,8 +4,6 @@ command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
[program:horizon]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
priority = 4
|
||||
autostart = %(ENV_WITH_HORIZON)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
stopwaitsecs = 3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = false
|
||||
startsecs = 0
|
||||
startretries = 1
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
|
||||
[program:reverb]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
priority = 3
|
||||
autostart = %(ENV_WITH_REVERB)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
minfds = 10000
|
||||
|
||||
[program:inertia-ssr-server]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||
user = %(ENV_USER)s
|
||||
priority = 2
|
||||
autostart = %(ENV_WITH_SSR)s
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||
stdout_logfile_maxbytes = 200MB
|
||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||
stderr_logfile_maxbytes = 200MB
|
||||
@@ -4,8 +4,6 @@ command = %(ENV_WORKER_COMMAND)s
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { adminTest as test, expect } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||
|
||||
/**
|
||||
* Index pages of all Filament resources that ship with the core application.
|
||||
* Resources provided by extensions are intentionally left out, because
|
||||
* extensions are not installed in CI.
|
||||
*/
|
||||
const resourcePages = [
|
||||
{ path: '/admin/time-entries', heading: 'Time Entries' },
|
||||
{ path: '/admin/projects', heading: 'Projects' },
|
||||
{ path: '/admin/tasks', heading: 'Tasks' },
|
||||
{ path: '/admin/clients', heading: 'Clients' },
|
||||
{ path: '/admin/tags', heading: 'Tags' },
|
||||
{ path: '/admin/reports', heading: 'Reports' },
|
||||
{ path: '/admin/users', heading: 'Users' },
|
||||
{ path: '/admin/organizations', heading: 'Organizations' },
|
||||
{ path: '/admin/organization-invitations', heading: 'Invitations' },
|
||||
{ path: '/admin/project-members', heading: 'Project Members' },
|
||||
{ path: '/admin/tokens', heading: 'Tokens' },
|
||||
{ path: '/admin/failed-jobs', heading: 'Failed Jobs' },
|
||||
{ path: '/admin/audits', heading: 'Audits' },
|
||||
];
|
||||
|
||||
test.describe('Admin Panel Access', () => {
|
||||
test.use({ storageState: undefined });
|
||||
|
||||
test('test that the admin panel redirects guests to the login page', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/admin');
|
||||
await expect(page).toHaveURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
});
|
||||
|
||||
test('test that the admin panel is forbidden for users that are no super admin', async ({
|
||||
page,
|
||||
}) => {
|
||||
const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('John Doe');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
|
||||
await page.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD);
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
|
||||
const response = await page.goto(PLAYWRIGHT_BASE_URL + '/admin');
|
||||
expect(response?.status()).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin Panel', () => {
|
||||
test('test that the admin dashboard loads with all widgets', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/admin');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
|
||||
|
||||
// The widgets are loaded lazily via Livewire, so they only show up once
|
||||
// their content has been rendered.
|
||||
await expect(page.getByText('Total real users')).toBeVisible();
|
||||
await expect(page.getByText('Placeholder users')).toBeVisible();
|
||||
await expect(page.getByText('Active users in the last seven days')).toBeVisible();
|
||||
await expect(page.getByText('User Registrations', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Time Entries Created', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Time Entries Imported', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const { path, heading } of resourcePages) {
|
||||
test(`test that the admin page ${path} loads`, async ({ page }) => {
|
||||
const response = await page.goto(PLAYWRIGHT_BASE_URL + path);
|
||||
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.getByRole('heading', { name: heading, level: 1 })).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
|
||||
|
||||
/**
|
||||
* The seeded user that is allowed to access the Filament admin panel.
|
||||
*
|
||||
* Panel access is granted by email address via the `SUPER_ADMINS` config, so
|
||||
* the admin panel tests cannot use the throwaway users created by the default
|
||||
* fixture and rely on the seeded user instead.
|
||||
*/
|
||||
export const SUPER_ADMIN_EMAIL = 'admin@example.com';
|
||||
export const SEEDED_USER_PASSWORD = 'password';
|
||||
|
||||
/**
|
||||
* Log in as the seeded super admin user via the regular application login.
|
||||
*/
|
||||
export async function loginAsSuperAdmin(page: Page): Promise<void> {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.getByLabel('Email').fill(SUPER_ADMIN_EMAIL);
|
||||
await page.getByLabel('Password').fill(SEEDED_USER_PASSWORD);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "feature/composer-update"
|
||||
"ref": "v0.0.6"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
"ref": "feature/composer-update"
|
||||
"ref": "v0.0.2"
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "feature/composer-update"
|
||||
"ref": "v0.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,5 @@
|
||||
<env name="NEWSLETTER_URL" value="null"/>
|
||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
||||
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
|
||||
<env name="PADDLE_SANDBOX" value="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
|
||||
import { type TestContext, setupTestContext } from '../e2e/utils/api';
|
||||
import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';
|
||||
import { loginAsSuperAdmin } from '../e2e/utils/admin';
|
||||
|
||||
export * from '@playwright/test';
|
||||
export type { TestContext };
|
||||
@@ -119,33 +116,3 @@ export const test = baseTest.extend<
|
||||
await closeAdmin();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Authentication fixture for the Filament admin panel.
|
||||
*
|
||||
* The admin panel can only be accessed by the seeded super admin user, so the
|
||||
* throwaway users of the default fixture cannot be used. Logging in is rate
|
||||
* limited (5 attempts per minute and email address), therefore the session is
|
||||
* created once per worker and reused by all tests of that worker.
|
||||
*/
|
||||
export const adminTest = baseTest.extend<object, { adminStorageState: string }>({
|
||||
adminStorageState: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const fileName = path.resolve(
|
||||
workerInfo.project.outputDir,
|
||||
`.auth/admin-${workerInfo.workerIndex}.json`
|
||||
);
|
||||
|
||||
if (!fs.existsSync(fileName)) {
|
||||
const context = await browser.newContext({ storageState: undefined });
|
||||
await loginAsSuperAdmin(await context.newPage());
|
||||
await context.storageState({ path: fileName });
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await use(fileName);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
storageState: ({ adminStorageState }, use) => use(adminStorageState),
|
||||
});
|
||||
|
||||
1
public/fonts/filament/filament/inter/index.css
Normal file
1
public/fonts/filament/filament/inter/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-DR6K5BQD.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-A2J6H3EX.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-ZABHMKQG.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-VWEHJHBA.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-ZIT2UBIY.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-DIHYUR35.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
||||
"Project","Client","Status","Visibility","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Project members","Project manager","Note"
|
||||
"Project for Big Company","Big Company","Active","Public","Task 1, Task 2, Task 3","1.38","","","","","Constantin Graf","",""
|
||||
"Project without Client","","Active","Public","","0.00","","","","","Constantin Graf","",""
|
||||
"TEST","Big Company","Active","Public","","0.00","","","","","Constantin Graf","",""
|
||||
|
@@ -19,7 +19,7 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
@routes
|
||||
@vite(\Nwidart\Modules\Module::getAssets())
|
||||
@vite(array_filter(\Nwidart\Modules\Module::getAssets(), fn($asset) => $asset !== 'resources/css/filament/admin/theme.css'))
|
||||
@inertiaHead
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
|
||||
@@ -7,25 +7,14 @@ namespace Tests\Unit\Filament\Resources;
|
||||
use App\Enums\Role;
|
||||
use App\Events\OrganizationInvitationAdding;
|
||||
use App\Filament\Resources\OrganizationResource;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\Export\ExportService;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use App\Service\Import\ImportService;
|
||||
use Exception;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
@@ -92,239 +81,6 @@ class OrganizationResourceTest extends FilamentTestCase
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_can_delete_an_organization_from_the_table(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(DeletionService::class, function (MockInterface $mock) use ($organization): void {
|
||||
$mock->shouldReceive('deleteOrganization')
|
||||
->withArgs(fn (Organization $organizationArg) => $organizationArg->is($organization))
|
||||
->once();
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('delete', $organization);
|
||||
|
||||
// Assert
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_can_export_an_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
Storage::fake(config('filesystems.private'));
|
||||
Storage::disk(config('filesystems.private'))->put('exports/export.zip', 'zip-content');
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(ExportService::class, function (MockInterface $mock) use ($organization): void {
|
||||
$mock->shouldReceive('export')
|
||||
->withArgs(fn (Organization $organizationArg) => $organizationArg->is($organization))
|
||||
->once()
|
||||
->andReturn('exports/export.zip');
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('Export', $organization);
|
||||
|
||||
// Assert
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->title('Export successful')
|
||||
->success()
|
||||
->persistent()
|
||||
);
|
||||
$response->assertFileDownloaded('export.zip');
|
||||
}
|
||||
|
||||
public function test_export_organization_shows_error_notification_on_failure(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(ExportService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('export')
|
||||
->once()
|
||||
->andThrow(new Exception('Something went wrong'));
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('Export', $organization);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->title('Export failed')
|
||||
->danger()
|
||||
->body('Message: Something went wrong')
|
||||
->persistent()
|
||||
);
|
||||
$response->assertNoFileDownloaded();
|
||||
}
|
||||
|
||||
public function test_can_import_into_an_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
Storage::fake(config('filament.default_filesystem_disk'));
|
||||
Storage::disk(config('filament.default_filesystem_disk'))->put('imports/import.csv', 'csv-content');
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(ImportService::class, function (MockInterface $mock) use ($organization): void {
|
||||
$mock->shouldReceive('import')
|
||||
->withArgs(function (Organization $organizationArg, string $importerType, string $data, string $timezone) use ($organization): bool {
|
||||
return $organizationArg->is($organization)
|
||||
&& $importerType === 'toggl_time_entries'
|
||||
&& $data === 'csv-content'
|
||||
&& $timezone === 'Europe/Vienna';
|
||||
})
|
||||
->once()
|
||||
->andReturn(new ReportDto(
|
||||
clientsCreated: 1,
|
||||
projectsCreated: 2,
|
||||
tasksCreated: 3,
|
||||
timeEntriesCreated: 4,
|
||||
tagsCreated: 5,
|
||||
usersCreated: 6,
|
||||
));
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('Import', $organization, data: [
|
||||
'file' => [(string) Str::uuid() => 'imports/import.csv'],
|
||||
'type' => 'toggl_time_entries',
|
||||
'timezone' => 'Europe/Vienna',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->title('Import successful')
|
||||
->success()
|
||||
->body(
|
||||
'Imported time entries: 4<br>'.
|
||||
'Imported clients: 1<br>'.
|
||||
'Imported projects: 2<br>'.
|
||||
'Imported tasks: 3<br>'.
|
||||
'Imported tags: 5<br>'.
|
||||
'Imported users: 6'
|
||||
)
|
||||
->persistent()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_import_into_organization_shows_error_notification_on_import_exception(): void
|
||||
{
|
||||
// Arrange
|
||||
Storage::fake(config('filament.default_filesystem_disk'));
|
||||
Storage::disk(config('filament.default_filesystem_disk'))->put('imports/import.csv', 'csv-content');
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(ImportService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('import')
|
||||
->once()
|
||||
->andThrow(new ImportException('Invalid CSV'));
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('Import', $organization, data: [
|
||||
'file' => [(string) Str::uuid() => 'imports/import.csv'],
|
||||
'type' => 'toggl_time_entries',
|
||||
'timezone' => 'Europe/Vienna',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->title('Import failed, changes rolled back')
|
||||
->danger()
|
||||
->body('Message: Invalid CSV')
|
||||
->persistent()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_import_into_organization_shows_error_notification_if_file_is_missing(): void
|
||||
{
|
||||
// Arrange
|
||||
Storage::fake(config('filament.default_filesystem_disk'));
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(ImportService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldNotReceive('import');
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('Import', $organization, data: [
|
||||
'file' => [(string) Str::uuid() => 'imports/does-not-exist.csv'],
|
||||
'type' => 'toggl_time_entries',
|
||||
'timezone' => 'Europe/Vienna',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->title('Import failed, changes rolled back')
|
||||
->danger()
|
||||
->body('Message: File not found')
|
||||
->persistent()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_import_into_organization_shows_error_notification_if_file_is_missing_on_non_throwing_disk(): void
|
||||
{
|
||||
// Arrange
|
||||
Storage::fake(config('filament.default_filesystem_disk'), ['throw' => false]);
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(ImportService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldNotReceive('import');
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('Import', $organization, data: [
|
||||
'file' => [(string) Str::uuid() => 'imports/does-not-exist.csv'],
|
||||
'type' => 'toggl_time_entries',
|
||||
'timezone' => 'Europe/Vienna',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->title('Import failed, changes rolled back')
|
||||
->danger()
|
||||
->body('Message: File not found')
|
||||
->persistent()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_import_into_organization_requires_file_type_and_timezone(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$this->mock(ImportService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldNotReceive('import');
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
|
||||
->callTableAction('Import', $organization, data: [
|
||||
'file' => null,
|
||||
'type' => null,
|
||||
'timezone' => null,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertHasTableActionErrors(['file', 'type', 'timezone']);
|
||||
}
|
||||
|
||||
public function test_can_list_related_users(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -391,238 +147,4 @@ class OrganizationResourceTest extends FilamentTestCase
|
||||
Event::assertDispatched(OrganizationInvitationAdding::class);
|
||||
Mail::assertQueued(OrganizationInvitationMail::class);
|
||||
}
|
||||
|
||||
public function test_related_users_have_view_action_linking_to_user_resource(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertTableActionHasUrl('view', UserResource::getUrl('view', [
|
||||
'record' => $user->getKey(),
|
||||
]), $user);
|
||||
}
|
||||
|
||||
public function test_can_attach_user_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('attach', data: [
|
||||
'recordId' => $user->getKey(),
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_attach_user_to_organization_fails_with_owner_or_placeholder_role(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
foreach ([Role::Owner, Role::Placeholder] as $role) {
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('attach', data: [
|
||||
'recordId' => $user->getKey(),
|
||||
'role' => $role->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertHasTableActionErrors(['role']);
|
||||
}
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_edit_role_and_billable_rate_of_related_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->billableRate(1000)->create();
|
||||
$billableTimeEntry = TimeEntry::factory()->forMember($member)->billableRate(1000)->create();
|
||||
$nonBillableTimeEntry = TimeEntry::factory()->forMember($member)->notBillable()->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('edit', $user, data: [
|
||||
'role' => Role::Admin->value,
|
||||
'billable_rate' => 2000,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$member->refresh();
|
||||
$this->assertSame(Role::Admin->value, $member->role);
|
||||
$this->assertSame(2000, $member->billable_rate);
|
||||
$this->assertSame(2000, $billableTimeEntry->refresh()->billable_rate);
|
||||
$this->assertNull($nonBillableTimeEntry->refresh()->billable_rate);
|
||||
}
|
||||
|
||||
public function test_can_make_related_user_owner_of_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$owner = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($owner)->create();
|
||||
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->create();
|
||||
$user = User::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('edit', $user, data: [
|
||||
'role' => Role::Owner->value,
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$this->assertSame(Role::Owner->value, $member->refresh()->role);
|
||||
$this->assertSame(Role::Admin->value, $ownerMember->refresh()->role);
|
||||
$this->assertSame($user->getKey(), $organization->refresh()->user_id);
|
||||
}
|
||||
|
||||
public function test_edit_related_user_shows_error_notification_if_role_of_owner_is_changed(): void
|
||||
{
|
||||
// Arrange
|
||||
$owner = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($owner)->create();
|
||||
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('edit', $owner, data: [
|
||||
'role' => Role::Admin->value,
|
||||
'billable_rate' => null,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Update failed')
|
||||
->body(__('exceptions.api.organization_needs_at_least_one_owner'))
|
||||
->persistent()
|
||||
);
|
||||
$this->assertSame(Role::Owner->value, $ownerMember->refresh()->role);
|
||||
}
|
||||
|
||||
public function test_can_detach_related_user_from_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('detach', $user);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $member->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseHas(User::class, [
|
||||
'id' => $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_detach_related_user_shows_error_notification_if_user_is_owner(): void
|
||||
{
|
||||
// Arrange
|
||||
$owner = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($owner)->create();
|
||||
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('detach', $owner);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Delete failed')
|
||||
->body(__('exceptions.api.can_not_remove_owner_from_organization'))
|
||||
->persistent()
|
||||
);
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'id' => $ownerMember->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_detach_related_user_shows_error_notification_if_user_still_has_time_entries(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
TimeEntry::factory()->forMember($member)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
|
||||
'ownerRecord' => $organization,
|
||||
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
|
||||
])->callTableAction('detach', $user);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Delete failed')
|
||||
->body(__('exceptions.api.entity_still_in_use', [
|
||||
'modelToDelete' => __('validation.entities.member'),
|
||||
'modelInUse' => __('validation.entities.time_entry'),
|
||||
]))
|
||||
->persistent()
|
||||
);
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'id' => $member->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Tests\Unit\Filament\FilamentTestCase;
|
||||
|
||||
#[UsesClass(ProjectMemberResource::class)]
|
||||
class ProjectMemberResourceTest extends FilamentTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Config::set('auth.super_admins', ['admin@example.com']);
|
||||
$user = User::factory()->withPersonalOrganization()->create([
|
||||
'email' => 'admin@example.com',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
}
|
||||
|
||||
private function createProjectMember(?int $billableRate = null): ProjectMember
|
||||
{
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create();
|
||||
|
||||
return ProjectMember::factory()
|
||||
->forProject($project)
|
||||
->forMember($member)
|
||||
->create($billableRate !== null ? ['billable_rate' => $billableRate] : []);
|
||||
}
|
||||
|
||||
public function test_can_list_project_members(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMembers = collect([
|
||||
$this->createProjectMember(),
|
||||
$this->createProjectMember(),
|
||||
$this->createProjectMember(),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\ListProjectMembers::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCanSeeTableRecords($projectMembers);
|
||||
$response->assertCanRenderTableColumn('project.name');
|
||||
$response->assertCanRenderTableColumn('user.name');
|
||||
$response->assertCanRenderTableColumn('billable_rate');
|
||||
}
|
||||
|
||||
public function test_can_sort_project_members_by_billable_rate(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember1 = $this->createProjectMember(3000);
|
||||
$projectMember2 = $this->createProjectMember(1000);
|
||||
$projectMember3 = $this->createProjectMember(2000);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\ListProjectMembers::class)
|
||||
->sortTable('billable_rate');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCanSeeTableRecords([$projectMember2, $projectMember3, $projectMember1], inOrder: true);
|
||||
}
|
||||
|
||||
public function test_can_see_create_page_of_project_member(): void
|
||||
{
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\CreateProjectMember::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_can_see_edit_page_of_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember(1500);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\EditProjectMember::class, ['record' => $projectMember->getKey()]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertSchemaStateSet([
|
||||
'billable_rate' => 1500,
|
||||
'user_id' => $projectMember->user_id,
|
||||
'member_id' => $projectMember->member_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_see_view_page_of_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\ViewProjectMembers::class, ['record' => $projectMember->getKey()]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_can_update_billable_rate_of_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember(1500);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\EditProjectMember::class, ['record' => $projectMember->getKey()])
|
||||
->fillForm([
|
||||
'billable_rate' => 2500,
|
||||
])
|
||||
->call('save');
|
||||
|
||||
// Assert
|
||||
$response->assertHasNoFormErrors();
|
||||
$response->assertSuccessful();
|
||||
$projectMember->refresh();
|
||||
$this->assertSame(2500, $projectMember->billable_rate);
|
||||
}
|
||||
|
||||
public function test_can_remove_billable_rate_of_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember(1500);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\EditProjectMember::class, ['record' => $projectMember->getKey()])
|
||||
->fillForm([
|
||||
'billable_rate' => null,
|
||||
])
|
||||
->call('save');
|
||||
|
||||
// Assert
|
||||
$response->assertHasNoFormErrors();
|
||||
$response->assertSuccessful();
|
||||
$projectMember->refresh();
|
||||
$this->assertNull($projectMember->billable_rate);
|
||||
}
|
||||
|
||||
public function test_update_project_member_fails_if_billable_rate_is_not_positive(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember(1500);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\EditProjectMember::class, ['record' => $projectMember->getKey()])
|
||||
->fillForm([
|
||||
'billable_rate' => 0,
|
||||
])
|
||||
->call('save');
|
||||
|
||||
// Assert
|
||||
$response->assertHasFormErrors(['billable_rate']);
|
||||
$projectMember->refresh();
|
||||
$this->assertSame(1500, $projectMember->billable_rate);
|
||||
}
|
||||
|
||||
public function test_update_project_member_fails_if_billable_rate_exceeds_maximum(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember(1500);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\EditProjectMember::class, ['record' => $projectMember->getKey()])
|
||||
->fillForm([
|
||||
'billable_rate' => 2147483648,
|
||||
])
|
||||
->call('save');
|
||||
|
||||
// Assert
|
||||
$response->assertHasFormErrors(['billable_rate']);
|
||||
$projectMember->refresh();
|
||||
$this->assertSame(1500, $projectMember->billable_rate);
|
||||
}
|
||||
|
||||
public function test_update_project_member_fails_if_user_or_member_is_missing(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember(1500);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\EditProjectMember::class, ['record' => $projectMember->getKey()])
|
||||
->fillForm([
|
||||
'user_id' => null,
|
||||
'member_id' => null,
|
||||
])
|
||||
->call('save');
|
||||
|
||||
// Assert
|
||||
$response->assertHasFormErrors(['user_id', 'member_id']);
|
||||
}
|
||||
|
||||
public function test_can_delete_a_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember = $this->createProjectMember();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\EditProjectMember::class, ['record' => $projectMember->getKey()])
|
||||
->callAction('delete');
|
||||
|
||||
// Assert
|
||||
$response->assertHasNoActionErrors();
|
||||
$response->assertSuccessful();
|
||||
$this->assertDatabaseMissing(ProjectMember::class, [
|
||||
'id' => $projectMember->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_bulk_delete_project_members(): void
|
||||
{
|
||||
// Arrange
|
||||
$projectMember1 = $this->createProjectMember();
|
||||
$projectMember2 = $this->createProjectMember();
|
||||
$projectMember3 = $this->createProjectMember();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectMemberResource\Pages\ListProjectMembers::class)
|
||||
->callTableBulkAction('delete', [$projectMember1, $projectMember2]);
|
||||
|
||||
// Assert
|
||||
$response->assertHasNoTableBulkActionErrors();
|
||||
$response->assertSuccessful();
|
||||
$this->assertDatabaseMissing(ProjectMember::class, ['id' => $projectMember1->getKey()]);
|
||||
$this->assertDatabaseMissing(ProjectMember::class, ['id' => $projectMember2->getKey()]);
|
||||
$this->assertDatabaseHas(ProjectMember::class, ['id' => $projectMember3->getKey()]);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource;
|
||||
use App\Filament\Resources\ProjectResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Livewire\Livewire;
|
||||
@@ -54,56 +50,4 @@ class ProjectResourceTest extends FilamentTestCase
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_can_list_related_project_members(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create();
|
||||
$otherProject = Project::factory()->forOrganization($organization)->create();
|
||||
$projectMembers = collect();
|
||||
foreach (range(1, 3) as $i) {
|
||||
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
|
||||
$projectMembers->push(ProjectMember::factory()->forProject($project)->forMember($member)->create());
|
||||
}
|
||||
$otherMember = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
|
||||
$otherProjectMember = ProjectMember::factory()->forProject($otherProject)->forMember($otherMember)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectResource\RelationManagers\ProjectMembersRelationManager::class, [
|
||||
'ownerRecord' => $project,
|
||||
'pageClass' => ProjectResource\Pages\EditProject::class,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCanSeeTableRecords($projectMembers);
|
||||
$response->assertCanNotSeeTableRecords([$otherProjectMember]);
|
||||
$response->assertCanRenderTableColumn('user.name');
|
||||
$response->assertCanRenderTableColumn('billable_rate');
|
||||
}
|
||||
|
||||
public function test_related_project_members_have_view_and_edit_actions_linking_to_project_member_resource(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(ProjectResource\RelationManagers\ProjectMembersRelationManager::class, [
|
||||
'ownerRecord' => $project,
|
||||
'pageClass' => ProjectResource\Pages\EditProject::class,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertTableActionHasUrl('view', ProjectMemberResource::getUrl('view', [
|
||||
'record' => $projectMember->getKey(),
|
||||
]), $projectMember);
|
||||
$response->assertTableActionHasUrl('edit', ProjectMemberResource::getUrl('edit', [
|
||||
'record' => $projectMember->getKey(),
|
||||
]), $projectMember);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,12 @@ namespace Tests\Unit\Filament\Resources;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Filament\Resources\OrganizationResource;
|
||||
use App\Filament\Resources\TimeEntryResource;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Livewire;
|
||||
@@ -21,7 +19,7 @@ use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Tests\Unit\Filament\FilamentTestCase;
|
||||
|
||||
#[UsesClass(UserResource::class)]
|
||||
#[UsesClass(TimeEntryResource::class)]
|
||||
class UserResourceTest extends FilamentTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
@@ -192,202 +190,4 @@ class UserResourceTest extends FilamentTestCase
|
||||
$response->assertCanSeeTableRecords($user->ownedOrganizations()->get());
|
||||
$response->assertCanNotSeeTableRecords([$organization]);
|
||||
}
|
||||
|
||||
public function test_related_organizations_have_view_action_linking_to_organization_resource(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $user,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertTableActionHasUrl('view', OrganizationResource::getUrl('view', [
|
||||
'record' => $organization->getKey(),
|
||||
]), $organization);
|
||||
}
|
||||
|
||||
public function test_can_edit_role_of_related_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $user,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
])->callTableAction('edit', $organization, data: [
|
||||
'role' => Role::Admin->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$this->assertSame(Role::Admin->value, $member->refresh()->role);
|
||||
}
|
||||
|
||||
public function test_can_make_user_owner_of_related_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$owner = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($owner)->create();
|
||||
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->create();
|
||||
$user = User::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $user,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
])->callTableAction('edit', $organization, data: [
|
||||
'role' => Role::Owner->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$this->assertSame(Role::Owner->value, $member->refresh()->role);
|
||||
$this->assertSame(Role::Admin->value, $ownerMember->refresh()->role);
|
||||
$this->assertSame($user->getKey(), $organization->refresh()->user_id);
|
||||
}
|
||||
|
||||
public function test_edit_related_organization_shows_error_notification_if_role_of_owner_is_changed(): void
|
||||
{
|
||||
// Arrange
|
||||
$owner = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($owner)->create();
|
||||
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $owner,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
])->callTableAction('edit', $organization, data: [
|
||||
'role' => Role::Admin->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Update failed')
|
||||
->body(__('exceptions.api.organization_needs_at_least_one_owner'))
|
||||
->persistent()
|
||||
);
|
||||
$this->assertSame(Role::Owner->value, $ownerMember->refresh()->role);
|
||||
}
|
||||
|
||||
public function test_can_detach_related_organization_from_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $user,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
])->callTableAction('detach', $organization);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertHasNoTableActionErrors();
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $member->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseHas(Organization::class, [
|
||||
'id' => $organization->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_detach_related_organization_shows_error_notification_if_user_is_owner(): void
|
||||
{
|
||||
// Arrange
|
||||
$owner = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($owner)->create();
|
||||
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $owner,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
])->callTableAction('detach', $organization);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Delete failed')
|
||||
->body(__('exceptions.api.can_not_remove_owner_from_organization'))
|
||||
->persistent()
|
||||
);
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'id' => $ownerMember->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_detach_related_organization_shows_error_notification_if_user_still_has_time_entries(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||
TimeEntry::factory()->forMember($member)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $user,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
])->callTableAction('detach', $organization);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertNotified(
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Delete failed')
|
||||
->body(__('exceptions.api.entity_still_in_use', [
|
||||
'modelToDelete' => __('validation.entities.member'),
|
||||
'modelInUse' => __('validation.entities.time_entry'),
|
||||
]))
|
||||
->persistent()
|
||||
);
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'id' => $member->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_related_owned_organizations_have_view_and_edit_actions_linking_to_organization_resource(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($user)->create();
|
||||
Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserResource\RelationManagers\OwnedOrganizationsRelationManager::class, [
|
||||
'ownerRecord' => $user,
|
||||
'pageClass' => UserResource\Pages\EditUser::class,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertTableActionHasUrl('view', OrganizationResource::getUrl('view', [
|
||||
'record' => $organization->getKey(),
|
||||
]), $organization);
|
||||
$response->assertTableActionHasUrl('edit', OrganizationResource::getUrl('edit', [
|
||||
'record' => $organization->getKey(),
|
||||
]), $organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Widgets;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Tests\Unit\Filament\FilamentTestCase;
|
||||
|
||||
abstract class ChartWidgetTestCase extends FilamentTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Config::set('auth.super_admins', ['admin@example.com']);
|
||||
$user = User::factory()->withPersonalOrganization()->create([
|
||||
'email' => 'admin@example.com',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the protected getData() method of the chart widget of the given Livewire test.
|
||||
*
|
||||
* @param Testable<ChartWidget> $testable
|
||||
* @return array{datasets: array<int, array{label: string, data: Collection<int, int>}>, labels: Collection<int, string>}
|
||||
*/
|
||||
protected function getChartData(Testable $testable): array
|
||||
{
|
||||
/** @var ChartWidget $widget */
|
||||
$widget = $testable->instance();
|
||||
|
||||
/** @var array{datasets: array<int, array{label: string, data: Collection<int, int>}>, labels: Collection<int, string>} $data */
|
||||
$data = Closure::bind(fn (): array => $this->getData(), $widget, $widget)();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{datasets: array<int, array{label: string, data: Collection<int, int>}>, labels: Collection<int, string>} $data
|
||||
*/
|
||||
protected function sumOfChartData(array $data): int
|
||||
{
|
||||
return (int) $data['datasets'][0]['data']->sum();
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Widgets;
|
||||
|
||||
use App\Filament\Widgets\TimeEntriesCreated;
|
||||
use App\Models\TimeEntry;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[UsesClass(TimeEntriesCreated::class)]
|
||||
class TimeEntriesCreatedWidgetTest extends ChartWidgetTestCase
|
||||
{
|
||||
public function test_widget_renders_with_filters(): void
|
||||
{
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesCreated::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Time Entries Created');
|
||||
$response->assertSee('Last week');
|
||||
$response->assertSee('Last month');
|
||||
$response->assertSee('Last year');
|
||||
$response->assertSet('filter', 'week');
|
||||
}
|
||||
|
||||
public function test_counts_only_non_imported_time_entries_created_in_the_last_week_by_default(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => false],
|
||||
['created_at' => now()->subDays(2), 'is_imported' => false],
|
||||
['created_at' => now()->subDays(2), 'is_imported' => true],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesCreated::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame('Time Entries Created', $data['datasets'][0]['label']);
|
||||
$this->assertSame(2, $this->sumOfChartData($data));
|
||||
$this->assertCount(8, $data['labels']);
|
||||
$this->assertSame(now()->subWeek()->format('Y-m-d'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m-d'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_month_filter_counts_time_entries_created_in_the_last_month_per_day(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => false],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => false],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => true],
|
||||
['created_at' => now()->subMonths(2), 'is_imported' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesCreated::class)
|
||||
->set('filter', 'month');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertDispatched('updateChartData');
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(2, $this->sumOfChartData($data));
|
||||
$this->assertSame(now()->subMonth()->format('Y-m-d'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m-d'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_year_filter_counts_time_entries_created_in_the_last_year_per_month(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => false],
|
||||
['created_at' => now()->subMonths(3), 'is_imported' => false],
|
||||
['created_at' => now()->subMonths(3), 'is_imported' => true],
|
||||
['created_at' => now()->subYears(2), 'is_imported' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesCreated::class)
|
||||
->set('filter', 'year');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(2, $this->sumOfChartData($data));
|
||||
$this->assertCount(13, $data['labels']);
|
||||
$this->assertSame(now()->subYear()->format('Y-m'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_unknown_filter_falls_back_to_last_week(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => false],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesCreated::class)
|
||||
->set('filter', 'unknown');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(1, $this->sumOfChartData($data));
|
||||
$this->assertCount(8, $data['labels']);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Widgets;
|
||||
|
||||
use App\Filament\Widgets\TimeEntriesImported;
|
||||
use App\Models\TimeEntry;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[UsesClass(TimeEntriesImported::class)]
|
||||
class TimeEntriesImportedWidgetTest extends ChartWidgetTestCase
|
||||
{
|
||||
public function test_widget_renders_with_filters(): void
|
||||
{
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesImported::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Time Entries Imported');
|
||||
$response->assertSee('Last week');
|
||||
$response->assertSee('Last month');
|
||||
$response->assertSee('Last year');
|
||||
$response->assertSet('filter', 'week');
|
||||
}
|
||||
|
||||
public function test_counts_only_imported_time_entries_created_in_the_last_week_by_default(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => true],
|
||||
['created_at' => now()->subDays(2), 'is_imported' => true],
|
||||
['created_at' => now()->subDays(2), 'is_imported' => false],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => true],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesImported::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame('Time Entries Imported', $data['datasets'][0]['label']);
|
||||
$this->assertSame(2, $this->sumOfChartData($data));
|
||||
$this->assertCount(8, $data['labels']);
|
||||
$this->assertSame(now()->subWeek()->format('Y-m-d'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m-d'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_month_filter_counts_time_entries_created_in_the_last_month_per_day(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => true],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => true],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => false],
|
||||
['created_at' => now()->subMonths(2), 'is_imported' => true],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesImported::class)
|
||||
->set('filter', 'month');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertDispatched('updateChartData');
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(2, $this->sumOfChartData($data));
|
||||
$this->assertSame(now()->subMonth()->format('Y-m-d'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m-d'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_year_filter_counts_time_entries_created_in_the_last_year_per_month(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => true],
|
||||
['created_at' => now()->subMonths(3), 'is_imported' => true],
|
||||
['created_at' => now()->subMonths(3), 'is_imported' => false],
|
||||
['created_at' => now()->subYears(2), 'is_imported' => true],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesImported::class)
|
||||
->set('filter', 'year');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(2, $this->sumOfChartData($data));
|
||||
$this->assertCount(13, $data['labels']);
|
||||
$this->assertSame(now()->subYear()->format('Y-m'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_unknown_filter_falls_back_to_last_week(): void
|
||||
{
|
||||
// Arrange
|
||||
TimeEntry::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_imported' => true],
|
||||
['created_at' => now()->subDays(10), 'is_imported' => true],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TimeEntriesImported::class)
|
||||
->set('filter', 'unknown');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(1, $this->sumOfChartData($data));
|
||||
$this->assertCount(8, $data['labels']);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Widgets;
|
||||
|
||||
use App\Filament\Widgets\UserRegistrations;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[UsesClass(UserRegistrations::class)]
|
||||
class UserRegistrationsWidgetTest extends ChartWidgetTestCase
|
||||
{
|
||||
public function test_widget_renders_with_filters(): void
|
||||
{
|
||||
// Act
|
||||
$response = Livewire::test(UserRegistrations::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('User Registrations');
|
||||
$response->assertSee('Last week');
|
||||
$response->assertSee('Last month');
|
||||
$response->assertSee('Last year');
|
||||
$response->assertSet('filter', 'week');
|
||||
}
|
||||
|
||||
public function test_counts_only_non_placeholder_users_registered_in_the_last_week_by_default(): void
|
||||
{
|
||||
// Arrange
|
||||
// The super admin from setUp was created just now and is therefore counted as well.
|
||||
User::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_placeholder' => false],
|
||||
['created_at' => now()->subDays(2), 'is_placeholder' => false],
|
||||
['created_at' => now()->subDays(2), 'is_placeholder' => true],
|
||||
['created_at' => now()->subDays(10), 'is_placeholder' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserRegistrations::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame('User Registrations', $data['datasets'][0]['label']);
|
||||
$this->assertSame(3, $this->sumOfChartData($data));
|
||||
$this->assertCount(8, $data['labels']);
|
||||
$this->assertSame(now()->subWeek()->format('Y-m-d'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m-d'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_month_filter_counts_users_registered_in_the_last_month_per_day(): void
|
||||
{
|
||||
// Arrange
|
||||
User::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_placeholder' => false],
|
||||
['created_at' => now()->subDays(10), 'is_placeholder' => false],
|
||||
['created_at' => now()->subDays(10), 'is_placeholder' => true],
|
||||
['created_at' => now()->subMonths(2), 'is_placeholder' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserRegistrations::class)
|
||||
->set('filter', 'month');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertDispatched('updateChartData');
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(3, $this->sumOfChartData($data));
|
||||
$this->assertSame(now()->subMonth()->format('Y-m-d'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m-d'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_year_filter_counts_users_registered_in_the_last_year_per_month(): void
|
||||
{
|
||||
// Arrange
|
||||
User::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_placeholder' => false],
|
||||
['created_at' => now()->subMonths(3), 'is_placeholder' => false],
|
||||
['created_at' => now()->subMonths(3), 'is_placeholder' => true],
|
||||
['created_at' => now()->subYears(2), 'is_placeholder' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserRegistrations::class)
|
||||
->set('filter', 'year');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(3, $this->sumOfChartData($data));
|
||||
$this->assertCount(13, $data['labels']);
|
||||
$this->assertSame(now()->subYear()->format('Y-m'), $data['labels']->first());
|
||||
$this->assertSame(now()->format('Y-m'), $data['labels']->last());
|
||||
}
|
||||
|
||||
public function test_unknown_filter_falls_back_to_last_week(): void
|
||||
{
|
||||
// Arrange
|
||||
User::factory()->createMany([
|
||||
['created_at' => now()->subDay(), 'is_placeholder' => false],
|
||||
['created_at' => now()->subDays(10), 'is_placeholder' => false],
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(UserRegistrations::class)
|
||||
->set('filter', 'unknown');
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$data = $this->getChartData($response);
|
||||
$this->assertSame(2, $this->sumOfChartData($data));
|
||||
$this->assertCount(8, $data['labels']);
|
||||
}
|
||||
}
|
||||
@@ -119,24 +119,6 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
||||
);
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_without_billability_column_defaults_to_not_billable(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyProjectsImporter;
|
||||
$importer->init($organization);
|
||||
// Some Clockify exports don't contain a "Billability" column.
|
||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_5.csv');
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
|
||||
// Assert
|
||||
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project for Big Company')->firstOrFail();
|
||||
$this->assertFalse($project->is_billable);
|
||||
}
|
||||
|
||||
public function test_import_supports_activities_column_alias_for_tasks(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -66,27 +66,4 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(0, $report->projectsCreated);
|
||||
$this->assertSame(0, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_fails_if_task_name_is_too_long(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new GenericTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$taskName = str_repeat('a', 501);
|
||||
$data = "description,billable,client,project,tags,start,end,task,user_name,user_email\n".
|
||||
'"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","'.$taskName.'","Peter Tester","peter.test@email.test"';
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData($data, $timezone);
|
||||
} catch (ImportException $e) {
|
||||
// Assert
|
||||
$this->assertSame('Task name ("'.$taskName.'") is too long, maximum length is 500 characters', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user