mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-19 19:23:17 +01:00
Compare commits
15 Commits
47327aa52b
...
1ab271b38a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab271b38a | ||
|
|
b98d6b7a53 | ||
|
|
76c41e633f | ||
|
|
0e7f4b9fcc | ||
|
|
7e506d9fab | ||
|
|
9b123afee5 | ||
|
|
4943a38ffe | ||
|
|
12e355ff26 | ||
|
|
01b60c0f6a | ||
|
|
927da7dce9 | ||
|
|
c89fa87b0f | ||
|
|
02cf600f43 | ||
|
|
98a460725f | ||
|
|
95645ddd91 | ||
|
|
1de985b577 |
1
.env.ci
1
.env.ci
@@ -6,6 +6,7 @@ APP_DEBUG=true
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
APP_ENABLE_REGISTRATION=true
|
APP_ENABLE_REGISTRATION=true
|
||||||
|
SUPER_ADMINS=admin@example.com
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_CHANNEL=stack
|
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 }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
@@ -195,7 +195,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
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"
|
- name: "Install dependencies in billing extension"
|
||||||
uses: php-actions/composer@v6
|
uses: php-actions/composer@v6
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||||
with:
|
with:
|
||||||
working_dir: "extensions/Billing"
|
working_dir: "extensions/Billing"
|
||||||
command: install
|
command: install
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: rg.fr-par.scw.cloud/solidtime
|
registry: rg.fr-par.scw.cloud/solidtime
|
||||||
username: nologin
|
username: nologin
|
||||||
|
|||||||
8
.github/workflows/build-public.yml
vendored
8
.github/workflows/build-public.yml
vendored
@@ -117,13 +117,13 @@ jobs:
|
|||||||
${{ env.GHCR_REPO }}
|
${{ env.GHCR_REPO }}
|
||||||
|
|
||||||
- name: "Login to Docker Hub Container Registry"
|
- name: "Login to Docker Hub Container Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -177,13 +177,13 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GHCR"
|
- name: "Login to GHCR"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
31
.github/workflows/extension-manifest-check.yml
vendored
Normal file
31
.github/workflows/extension-manifest-check.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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
Normal file
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
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
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v7.0.0
|
uses: codecov/codecov-action@v7.1.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,6 +6,7 @@ dist
|
|||||||
/public/storage
|
/public/storage
|
||||||
/public/css
|
/public/css
|
||||||
/public/js
|
/public/js
|
||||||
|
/public/fonts/filament
|
||||||
/public/vendor
|
/public/vendor
|
||||||
/lang/vendor
|
/lang/vendor
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
@@ -43,4 +44,5 @@ yarn-error.log
|
|||||||
/data
|
/data
|
||||||
/config/caddy
|
/config/caddy
|
||||||
/config/composer
|
/config/composer
|
||||||
|
/config/psysh
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ use Filament\Tables\Columns\IconColumn;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use League\Flysystem\UnableToReadFile;
|
||||||
|
|
||||||
class OrganizationResource extends Resource
|
class OrganizationResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -180,10 +181,14 @@ class OrganizationResource extends Resource
|
|||||||
Action::make('Import')
|
Action::make('Import')
|
||||||
->icon('heroicon-o-inbox-arrow-down')
|
->icon('heroicon-o-inbox-arrow-down')
|
||||||
->action(function (Organization $record, array $data): void {
|
->action(function (Organization $record, array $data): void {
|
||||||
|
try {
|
||||||
try {
|
try {
|
||||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||||
|
} catch (UnableToReadFile) {
|
||||||
|
$file = null;
|
||||||
|
}
|
||||||
if ($file === null) {
|
if ($file === null) {
|
||||||
throw new Exception('File not found');
|
throw new ImportException('File not found');
|
||||||
}
|
}
|
||||||
/** @var string $timezone */
|
/** @var string $timezone */
|
||||||
$timezone = $data['timezone'];
|
$timezone = $data['timezone'];
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
], [
|
], [
|
||||||
'color' => $this->colorService->getRandomColor(),
|
'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,
|
'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,
|
'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,
|
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
||||||
@@ -55,6 +55,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
||||||
$tasks = explode(', ', $record[$tasksKey]);
|
$tasks = explode(', ', $record[$tasksKey]);
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
|
$this->checkTaskNameLength($task);
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task,
|
'name' => $task,
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
@@ -84,7 +85,6 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'Project',
|
'Project',
|
||||||
'Status',
|
'Status',
|
||||||
'Visibility',
|
'Visibility',
|
||||||
'Billability',
|
|
||||||
];
|
];
|
||||||
foreach ($requiredFields as $requiredField) {
|
foreach ($requiredFields as $requiredField) {
|
||||||
if (! in_array($requiredField, $header, true)) {
|
if (! in_array($requiredField, $header, true)) {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
|
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
|
||||||
|
$this->checkTaskNameLength($record[$taskKey]);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record[$taskKey],
|
'name' => $record[$taskKey],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use Override;
|
|||||||
|
|
||||||
abstract class DefaultImporter implements ImporterContract
|
abstract class DefaultImporter implements ImporterContract
|
||||||
{
|
{
|
||||||
|
protected const TASK_NAME_MAX_LENGTH = 500;
|
||||||
|
|
||||||
protected Organization $organization;
|
protected Organization $organization;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +184,16 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
$this->billableRateService = app(BillableRateService::class);
|
$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]
|
#[Override]
|
||||||
public function getReport(): ReportDto
|
public function getReport(): ReportDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($record['task'] !== '') {
|
if ($record['task'] !== '') {
|
||||||
|
$this->checkTaskNameLength($record['task']);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record['task'],
|
'name' => $record['task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($record['Task'] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
|
$this->checkTaskNameLength($record['Task']);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record['Task'],
|
'name' => $record['Task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
if ($projectId === null) {
|
if ($projectId === null) {
|
||||||
throw new Exception('Project does not exist');
|
throw new Exception('Project does not exist');
|
||||||
}
|
}
|
||||||
|
$this->checkTaskNameLength($task['name']);
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task['name'],
|
'name' => $task['name'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -153,9 +153,16 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
foreach ($projectMembers as $projectMember) {
|
foreach ($projectMembers as $projectMember) {
|
||||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
$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([
|
$this->projectMemberImportHelper->getKey([
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),
|
'member_id' => $memberId,
|
||||||
], [
|
], [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
|
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
|
||||||
@@ -182,6 +189,7 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
if ($projectId === null) {
|
if ($projectId === null) {
|
||||||
throw new Exception('Project does not exist');
|
throw new Exception('Project does not exist');
|
||||||
}
|
}
|
||||||
|
$this->checkTaskNameLength($task->name);
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task->name,
|
'name' => $task->name,
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($record['Task'] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
|
$this->checkTaskNameLength($record['Task']);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record['Task'],
|
'name' => $record['Task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ return [
|
|||||||
|
|
||||||
'ssr' => [
|
'ssr' => [
|
||||||
|
|
||||||
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
|
'enabled' => (bool) env('INERTIA_SSR_ENABLED', false),
|
||||||
|
|
||||||
'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
|
'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
ARG PHP_VERSION=8.3
|
ARG PHP_VERSION=8.3
|
||||||
ARG FRANKENPHP_VERSION=1.8
|
ARG FRANKENPHP_VERSION=1.11
|
||||||
ARG COMPOSER_VERSION=2.8
|
ARG COMPOSER_VERSION=2.8
|
||||||
ARG BUN_VERSION="latest"
|
|
||||||
ARG APP_ENV
|
ARG APP_ENV
|
||||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||||
|
|
||||||
@@ -16,13 +15,13 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build v2.10.0 \
|
xcaddy build \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
--with github.com/dunglas/caddy-cbrotli
|
--with github.com/dunglas/caddy-cbrotli
|
||||||
|
|
||||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
|
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||||
|
|
||||||
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
||||||
|
|
||||||
@@ -32,26 +31,28 @@ 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.source="https://github.com/solidtime-io/solidtime"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL"
|
LABEL org.opencontainers.image.licenses="AGPL"
|
||||||
|
|
||||||
ARG WWWUSER=1000
|
ARG USER_ID=1000
|
||||||
ARG WWWGROUP=1000
|
ARG GROUP_ID=1000
|
||||||
ARG TZ=UTC
|
ARG TZ=UTC
|
||||||
ARG APP_DIR=/var/www/html
|
|
||||||
ARG APP_ENV
|
|
||||||
ARG APP_HOST
|
|
||||||
ARG DOCKER_FILES_BASE_PATH
|
ARG DOCKER_FILES_BASE_PATH
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
TERM=xterm-color \
|
TERM=xterm-color \
|
||||||
OCTANE_SERVER=frankenphp \
|
OCTANE_SERVER=frankenphp \
|
||||||
TZ=${TZ} \
|
TZ=${TZ} \
|
||||||
USER=octane \
|
LANG=C.UTF-8 \
|
||||||
ROOT=${APP_DIR} \
|
USER=laravel \
|
||||||
APP_ENV=${APP_ENV} \
|
ROOT=/var/www/html \
|
||||||
|
APP_ENV=production \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
COMPOSER_FUND=0 \
|
COMPOSER_FUND=0 \
|
||||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
COMPOSER_MAX_PARALLEL_HTTP=48 \
|
||||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
WITH_HORIZON=false \
|
||||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
WITH_SCHEDULER=false \
|
||||||
SERVER_NAME=${APP_HOST}
|
WITH_REVERB=false \
|
||||||
|
WITH_SSR=false
|
||||||
|
|
||||||
|
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
|
||||||
|
|
||||||
WORKDIR ${ROOT}
|
WORKDIR ${ROOT}
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
|
|||||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||||
&& echo ${TZ} > /etc/timezone
|
&& 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; \
|
RUN apt-get update; \
|
||||||
apt-get upgrade -yqq; \
|
apt-get upgrade -yqq; \
|
||||||
apt-get install -yqq --no-install-recommends --show-progress \
|
apt-get install -yqq --no-install-recommends --show-progress \
|
||||||
@@ -68,40 +72,36 @@ RUN apt-get update; \
|
|||||||
wget \
|
wget \
|
||||||
vim \
|
vim \
|
||||||
git \
|
git \
|
||||||
|
unzip \
|
||||||
ncdu \
|
ncdu \
|
||||||
procps \
|
procps \
|
||||||
unzip \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
supervisor \
|
supervisor \
|
||||||
libsodium-dev \
|
libsodium-dev \
|
||||||
libbrotli-dev \
|
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
|
||||||
# Install PHP extensions (included with dunglas/frankenphp)
|
|
||||||
&& install-php-extensions \
|
&& install-php-extensions \
|
||||||
|
apcu \
|
||||||
bz2 \
|
bz2 \
|
||||||
pcntl \
|
pcntl \
|
||||||
mbstring \
|
mbstring \
|
||||||
bcmath \
|
bcmath \
|
||||||
sockets \
|
sockets \
|
||||||
pgsql \
|
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
opcache \
|
opcache \
|
||||||
exif \
|
exif \
|
||||||
pdo_mysql \
|
pdo_mysql \
|
||||||
zip \
|
zip \
|
||||||
uv \
|
uv \
|
||||||
vips \
|
|
||||||
intl \
|
intl \
|
||||||
gd \
|
gd \
|
||||||
redis \
|
redis \
|
||||||
rdkafka \
|
rdkafka \
|
||||||
memcached \
|
ffi \
|
||||||
igbinary \
|
|
||||||
ldap \
|
ldap \
|
||||||
&& apt-get -y autoremove \
|
&& apt-get -y autoremove \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& docker-php-source delete \
|
&& docker-php-source delete \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog
|
||||||
&& rm /var/log/lastlog /var/log/faillog
|
|
||||||
|
|
||||||
RUN arch="$(uname -m)" \
|
RUN arch="$(uname -m)" \
|
||||||
&& case "$arch" in \
|
&& case "$arch" in \
|
||||||
@@ -111,106 +111,64 @@ RUN arch="$(uname -m)" \
|
|||||||
x86) _cronic_fname='supercronic-linux-386' ;; \
|
x86) _cronic_fname='supercronic-linux-386' ;; \
|
||||||
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
||||||
esac \
|
esac \
|
||||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
|
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \
|
||||||
-O /usr/bin/supercronic \
|
-O /usr/bin/supercronic \
|
||||||
&& chmod +x /usr/bin/supercronic \
|
&& chmod +x /usr/bin/supercronic \
|
||||||
&& mkdir -p /etc/supercronic \
|
&& mkdir -p /etc/supercronic \
|
||||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
||||||
|
|
||||||
RUN userdel --remove --force www-data \
|
RUN userdel --remove --force www-data \
|
||||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
&& groupadd --force -g ${GROUP_ID} ${USER} \
|
||||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
|
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${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
|
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
||||||
|
|
||||||
USER ${USER}
|
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.* ./
|
||||||
|
|
||||||
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 \
|
#RUN composer install \
|
||||||
# --no-dev \
|
# --no-dev \
|
||||||
# --no-interaction \
|
# --no-interaction \
|
||||||
# --no-autoloader \
|
# --no-autoloader \
|
||||||
# --no-ansi \
|
# --no-ansi \
|
||||||
# --no-scripts \
|
# --no-scripts \
|
||||||
|
# --no-progress \
|
||||||
# --audit
|
# --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* ./
|
#COPY --link package.json bun.lock* ./
|
||||||
#
|
|
||||||
#RUN bun install --frozen-lockfile
|
#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 \
|
RUN mkdir -p \
|
||||||
storage/framework/{sessions,views,cache,testing} \
|
storage/framework/{sessions,views,cache,testing} \
|
||||||
storage/logs \
|
storage/logs \
|
||||||
bootstrap/cache && chmod -R a+rw storage
|
bootstrap/cache \
|
||||||
|
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||||
|
|
||||||
#RUN composer install \
|
RUN composer dump-autoload \
|
||||||
# --classmap-authoritative \
|
--optimize \
|
||||||
# --no-interaction \
|
--apcu \
|
||||||
# --no-ansi \
|
--no-dev
|
||||||
# --no-dev \
|
|
||||||
# && composer clear-cache
|
|
||||||
|
|
||||||
RUN cat .env
|
#RUN bun run build
|
||||||
#RUN php artisan env
|
|
||||||
|
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
|
||||||
|
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
USER ${USER}
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
#EXPOSE 2019
|
||||||
|
#EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
ENTRYPOINT ["start-container"]
|
||||||
|
|
||||||
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ elif [ "${container_mode}" = "reverb" ]; then
|
|||||||
echo "Healthcheck failed."
|
echo "Healthcheck failed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
elif [ "${container_mode}" = "worker" ]; then
|
||||||
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
|
|
||||||
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
||||||
|
|
||||||
|
log {
|
||||||
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_https off
|
||||||
|
|
||||||
|
skip_install_trust
|
||||||
|
|
||||||
frankenphp {
|
frankenphp {
|
||||||
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
||||||
}
|
}
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
|
|
||||||
{$CADDY_SERVER_SERVER_NAME} {
|
{$CADDY_SERVER_SERVER_NAME} {
|
||||||
log {
|
log {
|
||||||
level WARN
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
|
||||||
format filter {
|
format filter {
|
||||||
wrap {$CADDY_SERVER_LOGGER}
|
wrap {$CADDY_SERVER_LOGGER}
|
||||||
@@ -60,7 +68,6 @@
|
|||||||
error @rejected 401
|
error @rejected 401
|
||||||
|
|
||||||
php_server {
|
php_server {
|
||||||
index frankenphp-worker.php
|
|
||||||
try_files {path} frankenphp-worker.php
|
try_files {path} frankenphp-worker.php
|
||||||
resolve_root_symlink
|
resolve_root_symlink
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,18 @@
|
|||||||
[program:octane]
|
[program:octane]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
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
|
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
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
priority = 1
|
priority = 1
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopwaitsecs = 30
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
environment = LARAVEL_OCTANE = "1"
|
environment = LARAVEL_OCTANE = "1"
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
stderr_logfile_maxbytes = 0
|
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]
|
[include]
|
||||||
files = /etc/supervisord.conf
|
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
post_max_size = 100M
|
post_max_size = 100M
|
||||||
upload_max_filesize = 100M
|
upload_max_filesize = 100M
|
||||||
expose_php = 0
|
expose_php = 0
|
||||||
realpath_cache_size = 16M
|
realpath_cache_size = 32M
|
||||||
realpath_cache_ttl = 360
|
realpath_cache_ttl = 720
|
||||||
|
memory_limit = 256M
|
||||||
max_input_time = 5
|
max_input_time = 5
|
||||||
register_argc_argv = 0
|
register_argc_argv = 0
|
||||||
date.timezone = ${TZ:-UTC}
|
date.timezone = ${TZ:-UTC}
|
||||||
@@ -11,18 +12,24 @@ date.timezone = ${TZ:-UTC}
|
|||||||
[Opcache]
|
[Opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.enable_cli = 1
|
opcache.enable_cli = 1
|
||||||
opcache.memory_consumption = 256M
|
opcache.memory_consumption = 256
|
||||||
opcache.use_cwd = 0
|
opcache.use_cwd = 0
|
||||||
|
opcache.save_comments = 1
|
||||||
opcache.max_file_size = 0
|
opcache.max_file_size = 0
|
||||||
opcache.max_accelerated_files = 32531
|
opcache.max_accelerated_files = 32531
|
||||||
opcache.validate_timestamps = 0
|
opcache.validate_timestamps = 0
|
||||||
opcache.file_update_protection = 0
|
opcache.file_update_protection = 0
|
||||||
opcache.interned_strings_buffer = 16
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.enable_file_override = 1
|
||||||
|
opcache.file_cache_consistency_checks = 0
|
||||||
|
opcache.file_cache = /tmp/opcache-file-cache
|
||||||
|
|
||||||
[JIT]
|
[JIT]
|
||||||
opcache.jit_buffer_size = 128M
|
opcache.jit_buffer_size = 128M
|
||||||
opcache.jit = function
|
opcache.jit = tracing
|
||||||
opcache.jit_prof_threshold = 0.001
|
opcache.jit_hot_loop = 16
|
||||||
|
opcache.jit_hot_func = 32
|
||||||
|
opcache.jit_hot_return = 4
|
||||||
opcache.jit_max_root_traces = 2048
|
opcache.jit_max_root_traces = 2048
|
||||||
opcache.jit_max_side_traces = 256
|
opcache.jit_max_side_traces = 256
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon = true
|
nodaemon = true
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
logfile = /var/log/supervisor/supervisord.log
|
|
||||||
pidfile = /var/run/supervisord.pid
|
|
||||||
|
|
||||||
[supervisorctl]
|
[supervisorctl]
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan horizon
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[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,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan reverb:start
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ command = supercronic -overlapping /etc/supercronic/laravel
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
69
docker/prod/deployment/supervisord.services.conf
Normal file
69
docker/prod/deployment/supervisord.services.conf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[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,6 +4,8 @@ command = %(ENV_WORKER_COMMAND)s
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
75
e2e/admin-panel.spec.ts
Normal file
75
e2e/admin-panel.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
24
e2e/utils/admin.ts
Normal file
24
e2e/utils/admin.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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": {
|
"Billing": {
|
||||||
"repository": "solidtime-io/extension-billing",
|
"repository": "solidtime-io/extension-billing",
|
||||||
"ref": "v0.0.6"
|
"ref": "feature/composer-update"
|
||||||
},
|
},
|
||||||
"Services": {
|
"Services": {
|
||||||
"repository": "solidtime-io/extension-services",
|
"repository": "solidtime-io/extension-services",
|
||||||
"ref": "v0.0.2"
|
"ref": "feature/composer-update"
|
||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.6"
|
"ref": "feature/composer-update"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,5 +43,7 @@
|
|||||||
<env name="NEWSLETTER_URL" value="null"/>
|
<env name="NEWSLETTER_URL" value="null"/>
|
||||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
||||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" 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>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { test as baseTest } from '@playwright/test';
|
import { test as baseTest } from '@playwright/test';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
|
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
|
||||||
import { type TestContext, setupTestContext } from '../e2e/utils/api';
|
import { type TestContext, setupTestContext } from '../e2e/utils/api';
|
||||||
import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';
|
import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';
|
||||||
|
import { loginAsSuperAdmin } from '../e2e/utils/admin';
|
||||||
|
|
||||||
export * from '@playwright/test';
|
export * from '@playwright/test';
|
||||||
export type { TestContext };
|
export type { TestContext };
|
||||||
@@ -116,3 +119,33 @@ export const test = baseTest.extend<
|
|||||||
await closeAdmin();
|
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 +0,0 @@
|
|||||||
@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.
4
resources/testfiles/clockify_projects_import_test_5.csv
Normal file
4
resources/testfiles/clockify_projects_import_test_5.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"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 -->
|
<!-- Scripts -->
|
||||||
@routes
|
@routes
|
||||||
@vite(array_filter(\Nwidart\Modules\Module::getAssets(), fn($asset) => $asset !== 'resources/css/filament/admin/theme.css'))
|
@vite(\Nwidart\Modules\Module::getAssets())
|
||||||
@inertiaHead
|
@inertiaHead
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
|
|||||||
@@ -7,14 +7,25 @@ namespace Tests\Unit\Filament\Resources;
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\OrganizationInvitationAdding;
|
use App\Events\OrganizationInvitationAdding;
|
||||||
use App\Filament\Resources\OrganizationResource;
|
use App\Filament\Resources\OrganizationResource;
|
||||||
|
use App\Filament\Resources\UserResource;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\DeletionService;
|
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\Config;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
@@ -81,6 +92,239 @@ class OrganizationResourceTest extends FilamentTestCase
|
|||||||
$response->assertSuccessful();
|
$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
|
public function test_can_list_related_users(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -147,4 +391,238 @@ class OrganizationResourceTest extends FilamentTestCase
|
|||||||
Event::assertDispatched(OrganizationInvitationAdding::class);
|
Event::assertDispatched(OrganizationInvitationAdding::class);
|
||||||
Mail::assertQueued(OrganizationInvitationMail::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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
245
tests/Unit/Filament/Resources/ProjectMemberResourceTest.php
Normal file
245
tests/Unit/Filament/Resources/ProjectMemberResourceTest.php
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<?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,8 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Filament\Resources;
|
namespace Tests\Unit\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProjectMemberResource;
|
||||||
use App\Filament\Resources\ProjectResource;
|
use App\Filament\Resources\ProjectResource;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectMember;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@@ -50,4 +54,56 @@ class ProjectResourceTest extends FilamentTestCase
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertSuccessful();
|
$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,12 +6,14 @@ namespace Tests\Unit\Filament\Resources;
|
|||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\OrganizationResource;
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\DeletionService;
|
use App\Service\DeletionService;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@@ -19,7 +21,7 @@ use Mockery\MockInterface;
|
|||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Tests\Unit\Filament\FilamentTestCase;
|
use Tests\Unit\Filament\FilamentTestCase;
|
||||||
|
|
||||||
#[UsesClass(TimeEntryResource::class)]
|
#[UsesClass(UserResource::class)]
|
||||||
class UserResourceTest extends FilamentTestCase
|
class UserResourceTest extends FilamentTestCase
|
||||||
{
|
{
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -190,4 +192,202 @@ class UserResourceTest extends FilamentTestCase
|
|||||||
$response->assertCanSeeTableRecords($user->ownedOrganizations()->get());
|
$response->assertCanSeeTableRecords($user->ownedOrganizations()->get());
|
||||||
$response->assertCanNotSeeTableRecords([$organization]);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
tests/Unit/Filament/Widgets/ChartWidgetTestCase.php
Normal file
52
tests/Unit/Filament/Widgets/ChartWidgetTestCase.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
116
tests/Unit/Filament/Widgets/TimeEntriesCreatedWidgetTest.php
Normal file
116
tests/Unit/Filament/Widgets/TimeEntriesCreatedWidgetTest.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
tests/Unit/Filament/Widgets/TimeEntriesImportedWidgetTest.php
Normal file
116
tests/Unit/Filament/Widgets/TimeEntriesImportedWidgetTest.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
tests/Unit/Filament/Widgets/UserRegistrationsWidgetTest.php
Normal file
117
tests/Unit/Filament/Widgets/UserRegistrationsWidgetTest.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?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,6 +119,24 @@ 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
|
public function test_import_supports_activities_column_alias_for_tasks(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -66,4 +66,27 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(0, $report->projectsCreated);
|
$this->assertSame(0, $report->projectsCreated);
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
$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