Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
47327aa52b Updated compose dependencies 2026-09-21 16:08:58 +02:00
55 changed files with 180 additions and 1960 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}

View File

@@ -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","",""
1 Project Client Status Visibility Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Progress(%) Project members Project manager Note
2 Project for Big Company Big Company Active Public Task 1, Task 2, Task 3 1.38 Constantin Graf
3 Project without Client Active Public 0.00 Constantin Graf
4 TEST Big Company Active Public 0.00 Constantin Graf

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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