Compare commits

..

10 commits

Author SHA1 Message Date
Jan Böhmer
e10bf89d6d Merge remote-tracking branch 'origin/master'
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-05-11 23:26:00 +02:00
Jan Böhmer
23431d3d31 Merge branch 'improvements_bugfixes' 2026-05-11 23:25:56 +02:00
Jan Böhmer
3431320d03 Fixed potential bugs 2026-05-11 23:25:21 +02:00
Jan Böhmer
2ae433a74d
Remove Scrutinizer Code Quality badge
Removed Scrutinizer Code Quality badge from README.
2026-05-11 23:15:46 +02:00
Jan Böhmer
a6ef9a58ec Merge branch 'improve_test_coverage' 2026-05-11 23:13:53 +02:00
Jan Böhmer
112e962239 Test some more edge cases in tests 2026-05-11 23:13:46 +02:00
Jan Böhmer
47ab18175f Test/Validate authentication of endpoints 2026-05-11 22:46:00 +02:00
Jan Böhmer
7d27bff062 Added more tests 2026-05-11 21:50:33 +02:00
Jan Böhmer
f3f93a8205 Updated dependencies 2026-05-11 20:31:12 +02:00
Jan Böhmer
65a6f46369 Added additional tests 2026-05-11 20:28:40 +02:00
36 changed files with 4690 additions and 1775 deletions

View file

@ -1,4 +1,3 @@
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Part-DB/Part-DB-symfony/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Part-DB/Part-DB-symfony/?branch=master)
![PHPUnit Tests](https://github.com/Part-DB/Part-DB-symfony/workflows/PHPUnit%20Tests/badge.svg) ![PHPUnit Tests](https://github.com/Part-DB/Part-DB-symfony/workflows/PHPUnit%20Tests/badge.svg)
![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg) ![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg)
[![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server) [![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server)

436
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -205,7 +205,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* supports?: string|list<scalar|Param|null>, * supports?: string|list<scalar|Param|null>,
* definition_validators?: list<scalar|Param|null>, * definition_validators?: list<scalar|Param|null>,
* support_strategy?: scalar|Param|null, * support_strategy?: scalar|Param|null,
* initial_marking?: backed-enum|string|list<scalar|Param|null>, * initial_marking?: \BackedEnum|string|list<scalar|Param|null>,
* events_to_dispatch?: null|list<string|Param>, * events_to_dispatch?: null|list<string|Param>,
* places?: string|list<array{ // Default: [] * places?: string|list<array{ // Default: []
* name?: scalar|Param|null, * name?: scalar|Param|null,
@ -214,11 +214,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* transitions?: list<array{ // Default: [] * transitions?: list<array{ // Default: []
* name?: string|Param, * name?: string|Param,
* guard?: string|Param, // An expression to block the transition. * guard?: string|Param, // An expression to block the transition.
* from?: backed-enum|string|list<array{ // Default: [] * from?: \BackedEnum|string|list<array{ // Default: []
* place?: string|Param, * place?: string|Param,
* weight?: int|Param, // Default: 1 * weight?: int|Param, // Default: 1
* }>, * }>,
* to?: backed-enum|string|list<array{ // Default: [] * to?: \BackedEnum|string|list<array{ // Default: []
* place?: string|Param, * place?: string|Param,
* weight?: int|Param, // Default: 1 * weight?: int|Param, // Default: 1
* }>, * }>,

View file

@ -156,8 +156,8 @@ class AttachmentManager
//Taken from: https://www.php.net/manual/de/function.filesize.php#106569 and slightly modified //Taken from: https://www.php.net/manual/de/function.filesize.php#106569 and slightly modified
$sz = 'BKMGTP'; $sz = 'BKMGTP';
$factor = (int) floor((strlen((string) $bytes) - 1) / 3); $factor = min((int) floor((strlen((string) $bytes) - 1) / 3), strlen($sz) - 1);
//Use real (10 based) SI prefixes //Use real (10 based) SI prefixes
return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).@$sz[$factor]; return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).$sz[$factor];
} }
} }

View file

@ -59,10 +59,10 @@ class SIFormatter
$prefixes_neg = ['', 'm', 'μ', 'n', 'p', 'f', 'a', 'z', 'y']; $prefixes_neg = ['', 'm', 'μ', 'n', 'p', 'f', 'a', 'z', 'y'];
if ($magnitude >= 0) { if ($magnitude >= 0) {
$nearest = (int) floor(abs($magnitude) / 3); $nearest = min((int) floor(abs($magnitude) / 3), count($prefixes_pos) - 1);
$symbol = $prefixes_pos[$nearest]; $symbol = $prefixes_pos[$nearest];
} else { } else {
$nearest = (int) round(abs($magnitude) / 3); $nearest = min((int) round(abs($magnitude) / 3), count($prefixes_neg) - 1);
$symbol = $prefixes_neg[$nearest]; $symbol = $prefixes_neg[$nearest];
} }

View file

@ -89,7 +89,7 @@ trait PKImportHelperTrait
//Use mime type to determine the extension like PartKeepr does in legacy implementation (just use the second part of the mime type) //Use mime type to determine the extension like PartKeepr does in legacy implementation (just use the second part of the mime type)
//See UploadedFile.php:291 in PartKeepr (https://github.com/partkeepr/PartKeepr/blob/f6176c3354b24fa39ac8bc4328ee0df91de3d5b6/src/PartKeepr/UploadedFileBundle/Entity/UploadedFile.php#L291) //See UploadedFile.php:291 in PartKeepr (https://github.com/partkeepr/PartKeepr/blob/f6176c3354b24fa39ac8bc4328ee0df91de3d5b6/src/PartKeepr/UploadedFileBundle/Entity/UploadedFile.php#L291)
if (!empty ($attachment_row['mimetype'])) { if (!empty ($attachment_row['mimetype'])) {
$attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1]; $attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1] ?? '';
} else { } else {
//If the mime type is empty, we use the original extension //If the mime type is empty, we use the original extension
$attachment_row['extension'] = pathinfo((string) $attachment_row['originalname'], PATHINFO_EXTENSION); $attachment_row['extension'] = pathinfo((string) $attachment_row['originalname'], PATHINFO_EXTENSION);

View file

@ -62,6 +62,9 @@ final readonly class GitVersionInfoProvider
{ {
if (is_file($this->getGitDirectory() . '/HEAD')) { if (is_file($this->getGitDirectory() . '/HEAD')) {
$git = file($this->getGitDirectory() . '/HEAD'); $git = file($this->getGitDirectory() . '/HEAD');
if ($git === false) {
return null;
}
$head = explode('/', $git[0], 3); $head = explode('/', $git[0], 3);
if (!isset($head[2])) { if (!isset($head[2])) {

View file

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Controller;
use App\Entity\UserSystem\User;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Verifies the HTTP access-control boundaries:
*
* The app has an "anonymous" fixture user with readonly permissions, so truly
* public read routes return 200 even without a session. Write-protected routes
* return 401 for unauthenticated requests (not a 302 redirect).
*
* Users: admin (all-allow), user (editor preset), noread (no group/no perms)
*/
#[Group('DB')]
#[Group('slow')]
final class AuthorizationTest extends WebTestCase
{
// -----------------------------------------------------------------------
// Data providers
// -----------------------------------------------------------------------
/**
* Routes readable by the anonymous user unauthenticated requests get 200.
*/
public static function publicReadRoutesProvider(): \Generator
{
yield 'homepage' => ['/en/'];
yield 'part view' => ['/en/part/1'];
yield 'statistics' => ['/en/statistics'];
yield 'select category' => ['/en/select_api/category'];
yield 'typeahead tags' => ['/en/typeahead/tags/search/test'];
}
/**
* Write-protected routes unauthenticated gets 401 (not 302).
*/
public static function writeProtectedRoutesProvider(): \Generator
{
yield 'part edit' => ['/en/part/1/edit'];
yield 'part new' => ['/en/part/new'];
yield 'user edit' => ['/en/user/1/edit'];
yield 'log list' => ['/en/log/'];
yield 'server info' => ['/en/tools/server_infos'];
}
/**
* Routes the `noread` user (no group = no permissions) must be denied.
*/
public static function noreadDeniedRoutesProvider(): \Generator
{
yield 'part view' => ['/en/part/1'];
yield 'part edit' => ['/en/part/1/edit'];
yield 'part new' => ['/en/part/new'];
yield 'log list' => ['/en/log/'];
yield 'server info' => ['/en/tools/server_infos'];
yield 'select category' => ['/en/select_api/category'];
yield 'typeahead tags' => ['/en/typeahead/tags/search/test'];
}
/**
* Routes the `user` (editor preset) must have access to.
*/
public static function editorAllowedRoutesProvider(): \Generator
{
yield 'homepage' => ['/en/'];
yield 'part view' => ['/en/part/1'];
yield 'part edit' => ['/en/part/1/edit'];
yield 'part new' => ['/en/part/new'];
yield 'select cat' => ['/en/select_api/category'];
yield 'typeahead' => ['/en/typeahead/tags/search/test'];
}
/**
* Admin-only routes the `user` (editor) must be denied.
*/
public static function editorDeniedRoutesProvider(): \Generator
{
yield 'user edit' => ['/en/user/1/edit'];
yield 'log list' => ['/en/log/'];
yield 'server info' => ['/en/tools/server_infos'];
}
/**
* Routes the `admin` user must be able to reach.
*/
public static function adminAllowedRoutesProvider(): \Generator
{
yield 'user edit' => ['/en/user/1/edit'];
yield 'log list' => ['/en/log/'];
yield 'server info' => ['/en/tools/server_infos'];
yield 'part view' => ['/en/part/1'];
yield 'part edit' => ['/en/part/1/edit'];
yield 'statistics' => ['/en/statistics'];
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private function loginAs(string $username): KernelBrowser
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['name' => $username]);
if ($user === null) {
$this->markTestSkipped("Fixture user '$username' not found.");
}
$client->loginUser($user);
$client->followRedirects(false);
return $client;
}
private function assertDenied(KernelBrowser $client, string $url): void
{
$client->request('GET', $url);
$code = $client->getResponse()->getStatusCode();
$this->assertTrue(
$code === Response::HTTP_FORBIDDEN || $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(),
"Expected 401/403/redirect on $url, got $code"
);
}
// -----------------------------------------------------------------------
// Unauthenticated: public reads
// -----------------------------------------------------------------------
#[DataProvider('publicReadRoutesProvider')]
public function testUnauthenticatedCanReadPublicRoutes(string $url): void
{
$client = static::createClient();
$client->request('GET', $url);
// Anonymous user (readonly group) can access read-only content
$this->assertResponseIsSuccessful();
}
// -----------------------------------------------------------------------
// Unauthenticated: write routes → 401
// -----------------------------------------------------------------------
#[DataProvider('writeProtectedRoutesProvider')]
public function testUnauthenticatedIsUnauthorizedOnWriteRoutes(string $url): void
{
$client = static::createClient();
$client->followRedirects(false);
$client->request('GET', $url);
$code = $client->getResponse()->getStatusCode();
$this->assertTrue(
$code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(),
"Expected 401 or redirect on $url for unauthenticated request, got $code"
);
}
// -----------------------------------------------------------------------
// noread user: denied everywhere
// -----------------------------------------------------------------------
#[DataProvider('noreadDeniedRoutesProvider')]
public function testNoreadUserIsDenied(string $url): void
{
$this->assertDenied($this->loginAs('noread'), $url);
}
// -----------------------------------------------------------------------
// Editor user
// -----------------------------------------------------------------------
#[DataProvider('editorAllowedRoutesProvider')]
public function testEditorCanAccess(string $url): void
{
$client = $this->loginAs('user');
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
}
#[DataProvider('editorDeniedRoutesProvider')]
public function testEditorIsDeniedOnAdminRoutes(string $url): void
{
$this->assertDenied($this->loginAs('user'), $url);
}
// -----------------------------------------------------------------------
// Admin user: can access everything
// -----------------------------------------------------------------------
#[DataProvider('adminAllowedRoutesProvider')]
public function testAdminCanAccessAllRoutes(string $url): void
{
$client = $this->loginAs('admin');
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
}
}

View file

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Controller;
use App\Entity\UserSystem\User;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Tests the SelectAPIController endpoints used by select2 widgets.
* These JSON endpoints back every structural-entity dropdown in the UI.
*/
#[Group('DB')]
#[Group('slow')]
final class SelectApiControllerTest extends WebTestCase
{
public static function endpointProvider(): \Generator
{
yield 'category' => ['/en/select_api/category'];
yield 'footprint' => ['/en/select_api/footprint'];
yield 'manufacturer' => ['/en/select_api/manufacturer'];
yield 'measurement_unit' => ['/en/select_api/measurement_unit'];
yield 'project' => ['/en/select_api/project'];
yield 'storage_location' => ['/en/select_api/storage_location'];
yield 'label_profiles' => ['/en/select_api/label_profiles'];
}
private function adminClient(): KernelBrowser
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$admin = $em->getRepository(User::class)->findOneBy(['name' => 'admin']);
if ($admin === null) {
$this->markTestSkipped('Fixture user admin not found.');
}
$client->loginUser($admin);
return $client;
}
// -----------------------------------------------------------------------
// Response format
// -----------------------------------------------------------------------
#[DataProvider('endpointProvider')]
public function testEndpointReturns200WithJsonContentType(string $url): void
{
$client = $this->adminClient();
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/json');
}
#[DataProvider('endpointProvider')]
public function testEndpointReturnsValidJsonArray(string $url): void
{
$client = $this->adminClient();
$client->request('GET', $url);
$body = $client->getResponse()->getContent();
$decoded = json_decode($body, true);
$this->assertIsArray($decoded, "Response from $url is not a valid JSON array");
}
#[DataProvider('endpointProvider')]
public function testEachEntryHasTextAndValueKeys(string $url): void
{
$client = $this->adminClient();
$client->request('GET', $url);
$decoded = json_decode($client->getResponse()->getContent(), true);
// Some endpoints include an empty "select none" entry at index 0; all entries must have text + value
foreach ($decoded as $entry) {
$this->assertArrayHasKey('text', $entry, "Entry in $url missing 'text' key");
$this->assertArrayHasKey('value', $entry, "Entry in $url missing 'value' key");
}
}
// -----------------------------------------------------------------------
// Access control
// -----------------------------------------------------------------------
#[DataProvider('endpointProvider')]
public function testUnauthenticatedCanReadSelectApi(string $url): void
{
// The anonymous user (readonly group) has read access to structural entities,
// so these endpoints return 200 even without a session.
$client = static::createClient();
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
}
#[DataProvider('endpointProvider')]
public function testNoreadUserIsDenied(string $url): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$noread = $em->getRepository(User::class)->findOneBy(['name' => 'noread']);
if ($noread === null) {
$this->markTestSkipped('Fixture user noread not found.');
}
$client->loginUser($noread);
$client->followRedirects(false);
$client->request('GET', $url);
$response = $client->getResponse();
$this->assertTrue(
$response->getStatusCode() === 403 || $response->isRedirect(),
"Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode()
);
}
#[DataProvider('endpointProvider')]
public function testEditorUserCanAccess(string $url): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['name' => 'user']);
if ($user === null) {
$this->markTestSkipped('Fixture user user not found.');
}
$client->loginUser($user);
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
}
}

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Controller;
use App\Entity\UserSystem\User;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Tests the TypeaheadController JSON endpoints that back autocomplete widgets in the UI.
*/
#[Group('DB')]
#[Group('slow')]
final class TypeaheadControllerTest extends WebTestCase
{
public static function endpointProvider(): \Generator
{
yield 'tags search' => ['/en/typeahead/tags/search/test'];
yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage'];
yield 'parameters category search' => ['/en/typeahead/parameters/category/search/NPN'];
yield 'builtin resources' => ['/en/typeahead/builtInResources/search?query=DIP'];
yield 'parts search' => ['/en/typeahead/parts/search/res'];
}
public static function partsReadEndpointProvider(): \Generator
{
// These require @parts.read — noread user must be denied
yield 'tags search' => ['/en/typeahead/tags/search/test'];
yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage'];
yield 'parts search' => ['/en/typeahead/parts/search/res'];
}
private function loginClient(string $username): KernelBrowser
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['name' => $username]);
if ($user === null) {
$this->markTestSkipped("Fixture user '$username' not found.");
}
$client->loginUser($user);
return $client;
}
// -----------------------------------------------------------------------
// Response format
// -----------------------------------------------------------------------
#[DataProvider('endpointProvider')]
public function testEndpointReturnsSuccessfulJsonForAdmin(string $url): void
{
$client = $this->loginClient('admin');
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
$this->assertJson($client->getResponse()->getContent());
}
#[DataProvider('endpointProvider')]
public function testEndpointReturnsJsonArray(string $url): void
{
$client = $this->loginClient('admin');
$client->request('GET', $url);
$decoded = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($decoded, "Response from $url should be a JSON array");
}
// -----------------------------------------------------------------------
// Tags search: result structure
// -----------------------------------------------------------------------
public function testTagsSearchReturnsStrings(): void
{
$client = $this->loginClient('admin');
$client->request('GET', '/en/typeahead/tags/search/a');
$tags = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($tags);
foreach ($tags as $tag) {
$this->assertIsString($tag, 'Each tag entry should be a plain string');
}
}
// -----------------------------------------------------------------------
// Parts search: result structure
// -----------------------------------------------------------------------
public function testPartsSearchReturnsArrayWithExpectedKeys(): void
{
$client = $this->loginClient('admin');
$client->request('GET', '/en/typeahead/parts/search/test');
$parts = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($parts);
// Each result must have at least id and name
foreach ($parts as $part) {
$this->assertArrayHasKey('id', $part);
$this->assertArrayHasKey('name', $part);
}
}
// -----------------------------------------------------------------------
// Access control
// -----------------------------------------------------------------------
#[DataProvider('endpointProvider')]
public function testUnauthenticatedCanAccessTypeahead(string $url): void
{
// Anonymous user (readonly group) has @parts.read, so these endpoints return 200.
$client = static::createClient();
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
}
#[DataProvider('partsReadEndpointProvider')]
public function testNoreadUserIsDenied(string $url): void
{
$client = $this->loginClient('noread');
$client->followRedirects(false);
$client->request('GET', $url);
$response = $client->getResponse();
$this->assertTrue(
$response->getStatusCode() === 403 || $response->isRedirect(),
"Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode()
);
}
#[DataProvider('endpointProvider')]
public function testEditorUserCanAccess(string $url): void
{
$client = $this->loginClient('user');
$client->request('GET', $url);
$this->assertResponseIsSuccessful();
}
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\EventSubscriber;
use App\EventSubscriber\MaintenanceModeSubscriber;
use App\Services\System\UpdateExecutor;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
final class MaintenanceModeSubscriberTest extends TestCase
{
private function makeSubscriber(bool $maintenanceActive): MaintenanceModeSubscriber
{
$executor = $this->createMock(UpdateExecutor::class);
$executor->method('isMaintenanceMode')->willReturn($maintenanceActive);
$executor->method('getMaintenanceInfo')->willReturn(
$maintenanceActive ? ['reason' => 'Test update', 'enabled_at' => date('Y-m-d H:i:s')] : null
);
return new MaintenanceModeSubscriber($executor);
}
private function makeEvent(string $url = 'http://example.com/'): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($url);
return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
}
public function testNoMaintenanceModeDoesNotSetResponse(): void
{
$subscriber = $this->makeSubscriber(false);
$event = $this->makeEvent();
$subscriber->onKernelRequest($event);
// When not in maintenance mode, no response is ever set regardless of SAPI
$this->assertFalse($event->hasResponse());
}
public function testCliRequestIsNeverBlocked(): void
{
// Tests run from CLI (PHP_SAPI === 'cli'), so maintenance mode never blocks CLI requests.
// This verifies the intentional behaviour: maintenance mode only affects web requests.
$subscriber = $this->makeSubscriber(true);
$event = $this->makeEvent();
$subscriber->onKernelRequest($event);
// CLI requests pass through even with maintenance active
$this->assertFalse($event->hasResponse());
}
public function testSubRequestIsIgnored(): void
{
$subscriber = $this->makeSubscriber(true);
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create('http://example.com/');
$event = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST);
$subscriber->onKernelRequest($event);
$this->assertFalse($event->hasResponse());
}
public function testSubscriberListensToKernelRequest(): void
{
$events = MaintenanceModeSubscriber::getSubscribedEvents();
$this->assertArrayHasKey(KernelEvents::REQUEST, $events);
}
public function testSubscriberListensWithHighPriority(): void
{
$events = MaintenanceModeSubscriber::getSubscribedEvents();
$config = $events[KernelEvents::REQUEST];
// Config is ['methodName', priority]
$priority = is_array($config) ? (int) ($config[1] ?? 0) : 0;
$this->assertGreaterThan(0, $priority, 'Maintenance subscriber should run with high priority');
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\EventSubscriber;
use App\EventSubscriber\RedirectToHttpsSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Security\Http\HttpUtils;
final class RedirectToHttpsSubscriberTest extends TestCase
{
private function makeEvent(string $url, bool $isMainRequest = true): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($url);
return new RequestEvent($kernel, $request, $isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
public function testHttpRequestIsRedirectedToHttpsWhenEnabled(): void
{
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
$event = $this->makeEvent('http://example.com/some/path');
$subscriber->onKernelRequest($event);
$this->assertTrue($event->hasResponse());
$response = $event->getResponse();
$this->assertStringStartsWith('https://', $response->getTargetUrl());
}
public function testHttpsRequestIsNotRedirectedWhenEnabled(): void
{
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
$event = $this->makeEvent('https://example.com/some/path');
$subscriber->onKernelRequest($event);
$this->assertFalse($event->hasResponse());
}
public function testHttpRequestIsNotRedirectedWhenDisabled(): void
{
$subscriber = new RedirectToHttpsSubscriber(false, new HttpUtils());
$event = $this->makeEvent('http://example.com/some/path');
$subscriber->onKernelRequest($event);
$this->assertFalse($event->hasResponse());
}
public function testSubRequestIsNotRedirected(): void
{
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
$event = $this->makeEvent('http://example.com/', false);
$subscriber->onKernelRequest($event);
$this->assertFalse($event->hasResponse());
}
public function testRedirectUrlPreservesPath(): void
{
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
$event = $this->makeEvent('http://example.com/admin/parts?q=test');
$subscriber->onKernelRequest($event);
$this->assertTrue($event->hasResponse());
$this->assertStringContainsString('/admin/parts', $event->getResponse()->getTargetUrl());
$this->assertStringContainsString('q=test', $event->getResponse()->getTargetUrl());
}
public function testSubscriberListensToKernelRequestEvent(): void
{
$events = RedirectToHttpsSubscriber::getSubscribedEvents();
$this->assertArrayHasKey('kernel.request', $events);
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\Cache;
use App\Entity\Parts\Part;
use App\Services\Cache\ElementCacheTagGenerator;
use PHPUnit\Framework\TestCase;
final class ElementCacheTagGeneratorTest extends TestCase
{
private ElementCacheTagGenerator $service;
protected function setUp(): void
{
$this->service = new ElementCacheTagGenerator();
}
public function testClassNameIsConvertedToTag(): void
{
$tag = $this->service->getElementTypeCacheTag(Part::class);
// Backslashes must be replaced by underscores
$this->assertStringNotContainsString('\\', $tag);
$this->assertSame(str_replace('\\', '_', Part::class), $tag);
}
public function testObjectInputGivesSameResultAsClassName(): void
{
$part = new Part();
$tagFromObject = $this->service->getElementTypeCacheTag($part);
$tagFromClass = $this->service->getElementTypeCacheTag(Part::class);
$this->assertSame($tagFromClass, $tagFromObject);
}
public function testResultIsCached(): void
{
$tag1 = $this->service->getElementTypeCacheTag(Part::class);
$tag2 = $this->service->getElementTypeCacheTag(Part::class);
$this->assertSame($tag1, $tag2);
}
public function testNonExistentClassThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->getElementTypeCacheTag('App\\NonExistent\\Foo');
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\Cache;
use App\Entity\UserSystem\User;
use App\Services\Cache\UserCacheKeyGenerator;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
final class UserCacheKeyGeneratorTest extends TestCase
{
private function makeGenerator(?User $loggedInUser, ?Request $request = null): UserCacheKeyGenerator
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($loggedInUser);
$requestStack = $this->createMock(RequestStack::class);
$requestStack->method('getCurrentRequest')->willReturn($request);
return new UserCacheKeyGenerator($security, $requestStack);
}
private function makeUserWithId(int $id): User
{
$user = new User();
$ref = new \ReflectionProperty(User::class, 'id');
$ref->setValue($user, $id);
return $user;
}
public function testAnonymousUserKeyContainsAnonymousId(): void
{
$service = $this->makeGenerator(null);
$key = $service->generateKey();
$this->assertStringContainsString((string) User::ID_ANONYMOUS, $key);
}
public function testExplicitAnonymousUserGivesSameKeyAsNull(): void
{
$anonUser = $this->makeUserWithId(User::ID_ANONYMOUS);
$anonUser->setName('anonymous');
$service = $this->makeGenerator(null);
$keyFromNull = $service->generateKey(null);
$keyFromAnon = $service->generateKey($anonUser);
$this->assertSame($keyFromNull, $keyFromAnon);
}
public function testKeyForRealUserContainsUserId(): void
{
$user = $this->makeUserWithId(42);
$service = $this->makeGenerator(null);
$key = $service->generateKey($user);
$this->assertStringContainsString('42', $key);
$this->assertStringNotContainsString((string) User::ID_ANONYMOUS, $key);
}
public function testLocaleFromRequestIsIncludedInKey(): void
{
$request = Request::create('/');
$request->setLocale('de');
$service = $this->makeGenerator(null, $request);
$key = $service->generateKey();
$this->assertStringContainsString('de', $key);
}
public function testDifferentUsersProduceDifferentKeys(): void
{
$service = $this->makeGenerator(null);
$user1 = $this->makeUserWithId(10);
$user2 = $this->makeUserWithId(20);
$this->assertNotSame($service->generateKey($user1), $service->generateKey($user2));
}
public function testCurrentlyLoggedInUserIsUsedWhenNoExplicitUser(): void
{
$loggedIn = $this->makeUserWithId(99);
$service = $this->makeGenerator($loggedIn);
$key = $service->generateKey();
$this->assertStringContainsString('99', $key);
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
use App\Services\EntityURLGenerator;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class EntityURLGeneratorTest extends WebTestCase
{
private static EntityURLGenerator $service;
public static function setUpBeforeClass(): void
{
self::bootKernel();
self::$service = self::getContainer()->get(EntityURLGenerator::class);
}
private function entityWithId(string $class, int $id): AbstractDBElement
{
$entity = new $class();
$ref = new \ReflectionProperty(AbstractDBElement::class, 'id');
$ref->setValue($entity, $id);
return $entity;
}
public function testInfoUrlForPartContainsPartPath(): void
{
$part = $this->entityWithId(Part::class, 1);
$url = self::$service->infoURL($part);
$this->assertStringContainsString('part', $url);
$this->assertStringContainsString('1', $url);
}
public function testEditUrlForCategoryContainsCategoryPath(): void
{
$category = $this->entityWithId(Category::class, 5);
$url = self::$service->editURL($category);
$this->assertStringContainsString('category', $url);
$this->assertStringContainsString('5', $url);
}
public function testListPartsUrlForSupplierContainsSupplierPath(): void
{
$supplier = $this->entityWithId(Supplier::class, 7);
$url = self::$service->listPartsURL($supplier);
$this->assertStringContainsString('supplier', $url);
}
public function testGetUrlWithInfoTypeCallsInfoUrl(): void
{
$part = $this->entityWithId(Part::class, 3);
$url = self::$service->getURL($part, 'info');
$this->assertStringContainsString('part', $url);
}
public function testGetUrlWithEditTypeCallsEditUrl(): void
{
$manufacturer = $this->entityWithId(Manufacturer::class, 2);
$url = self::$service->getURL($manufacturer, 'edit');
$this->assertStringContainsString('manufacturer', $url);
}
public function testGetUrlWithUnknownTypeThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$part = $this->entityWithId(Part::class, 1);
self::$service->getURL($part, 'unsupported_type');
}
public function testInfoUrlForUserContainsUserPath(): void
{
$user = $this->entityWithId(User::class, 10);
$url = self::$service->editURL($user);
$this->assertStringContainsString('user', $url);
}
public function testListPartsUrlForStorelocationContainsStorelocationPath(): void
{
$loc = $this->entityWithId(StorageLocation::class, 4);
$url = self::$service->listPartsURL($loc);
$this->assertStringContainsString('store', $url);
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\Formatters;
use App\Services\Formatters\MarkdownParser;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
final class MarkdownParserTest extends TestCase
{
private MarkdownParser $service;
protected function setUp(): void
{
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturn('Loading...');
$this->service = new MarkdownParser($translator);
}
public function testOutputContainsDataMarkdownAttribute(): void
{
$result = $this->service->markForRendering('**hello**');
$this->assertStringContainsString('data-markdown=', $result);
$this->assertStringContainsString('data-controller="common--markdown"', $result);
}
public function testMarkdownContentIsHtmlescapedInAttribute(): void
{
$result = $this->service->markForRendering('<script>alert(1)</script>');
// The raw < should not appear unescaped inside the attribute
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testInlineModeAddsInlineClass(): void
{
$result = $this->service->markForRendering('text', true);
$this->assertStringContainsString('markdown-inline', $result);
}
public function testNonInlineModeDoesNotAddInlineClass(): void
{
$result = $this->service->markForRendering('text', false);
$this->assertStringNotContainsString('markdown-inline', $result);
}
public function testOutputIsWrappedInDiv(): void
{
$result = $this->service->markForRendering('test');
$this->assertStringStartsWith('<div', $result);
$this->assertStringEndsWith('</div>', $result);
}
public function testTranslatorIsCalledForLoadingText(): void
{
$translator = $this->createMock(TranslatorInterface::class);
$translator->expects($this->once())
->method('trans')
->with('markdown.loading')
->willReturn('Loading...');
$service = new MarkdownParser($translator);
$service->markForRendering('test');
}
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\Formatters;
use App\Entity\PriceInformations\Currency;
use App\Services\Formatters\MoneyFormatter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class MoneyFormatterTest extends WebTestCase
{
private static MoneyFormatter $service;
public static function setUpBeforeClass(): void
{
self::bootKernel();
self::$service = self::getContainer()->get(MoneyFormatter::class);
}
public function testFormatWithFloatInput(): void
{
$currency = new Currency();
$currency->setIsoCode('USD');
$result = self::$service->format(1.5, $currency);
// Output format varies by locale, so verify content not exact form
$this->assertNotEmpty($result);
$this->assertStringContainsString('1', $result);
$this->assertTrue(
str_contains($result, '$') || str_contains($result, 'USD'),
"Expected USD indicator in: $result"
);
}
public function testFormatWithNullCurrencyUsesBaseCurrency(): void
{
$result = self::$service->format(1.5);
// Should return a non-empty formatted string
$this->assertNotEmpty($result);
$this->assertIsString($result);
}
public function testFormatWithExplicitCurrencyUsesThatCurrency(): void
{
$currency = new Currency();
$currency->setIsoCode('USD');
$result = self::$service->format(10.0, $currency);
$this->assertNotEmpty($result);
$this->assertStringContainsString('10', $result);
}
public function testFormatStringInputWorksSameAsFloat(): void
{
$resultFloat = self::$service->format(1.5);
$resultString = self::$service->format('1.5');
$this->assertSame($resultFloat, $resultString);
}
public function testShowAllDigitsRespectsFractionCount(): void
{
// With show_all_digits = true and decimals = 3, we expect exactly 3 decimal places
$result = self::$service->format(1.5, null, 3, true);
// The number should contain exactly 3 decimal digits
$this->assertMatchesRegularExpression('/\d{3}(?!\d)/', $result);
}
public function testZeroIsFormattedCorrectly(): void
{
$result = self::$service->format(0.0);
$this->assertNotEmpty($result);
$this->assertStringContainsString('0', $result);
}
public function testCurrencyWithEmptyIsoCodeFallsBackToBaseCurrency(): void
{
$currency = new Currency();
// Empty ISO code → should fall back to base currency
$resultWithEmpty = self::$service->format(1.0, $currency);
$resultWithNull = self::$service->format(1.0, null);
$this->assertSame($resultWithNull, $resultWithEmpty);
}
}

View file

@ -87,4 +87,32 @@ final class EventCommentHelperTest extends WebTestCase
$this->service->clearMessage(); $this->service->clearMessage();
$this->assertFalse($this->service->isMessageSet()); $this->assertFalse($this->service->isMessageSet());
} }
public function testEmptyStringTreatedAsNotSet(): void
{
// Empty string is falsy in PHP, so setMessage('') stores null internally
$this->service->setMessage('');
$this->assertFalse($this->service->isMessageSet());
$this->assertNull($this->service->getMessage());
}
public function testSetMessageNullClearsMessage(): void
{
$this->service->setMessage('Hello');
$this->service->setMessage(null);
$this->assertFalse($this->service->isMessageSet());
$this->assertNull($this->service->getMessage());
}
public function testLongMessageIsTruncated(): void
{
// MAX_MESSAGE_LENGTH is 255; a longer string should be truncated with '...' suffix
$long = str_repeat('a', 300);
$this->service->setMessage($long);
$stored = $this->service->getMessage();
$this->assertNotNull($stored);
$this->assertLessThanOrEqual(255, mb_strlen($stored));
$this->assertStringEndsWith('...', $stored);
}
} }

View file

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\LogSystem;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Services\LogSystem\LogDataFormatter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class LogDataFormatterTest extends WebTestCase
{
private static LogDataFormatter $service;
private static AbstractLogEntry $dummyLog;
private AbstractLogEntry $dummy;
public static function setUpBeforeClass(): void
{
self::bootKernel();
self::$service = self::getContainer()->get(LogDataFormatter::class);
}
protected function setUp(): void
{
parent::setUp();
// A mock is fine: $logEntry is only consulted for @id (foreign key) arrays
$this->dummy = $this->createMock(AbstractLogEntry::class);
}
public function testStringIsWrappedInQuoteSpans(): void
{
$result = self::$service->formatData('hello', $this->dummy, 'name');
$this->assertStringContainsString('"', $result);
$this->assertStringContainsString('hello', $result);
}
public function testStringSpecialCharsAreEscaped(): void
{
$result = self::$service->formatData('<script>', $this->dummy, 'name');
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testNewlineInStringRendersAsSpan(): void
{
$result = self::$service->formatData("line1\nline2", $this->dummy, 'name');
$this->assertStringContainsString('\\n', $result);
}
public function testBoolTrueFormatsAsString(): void
{
$result = self::$service->formatData(true, $this->dummy, 'enabled');
$this->assertIsString($result);
$this->assertNotEmpty($result);
}
public function testBoolFalseFormatsAsString(): void
{
$result = self::$service->formatData(false, $this->dummy, 'enabled');
$this->assertIsString($result);
$this->assertNotEmpty($result);
}
public function testBoolTrueAndFalseProduceDifferentOutput(): void
{
$true = self::$service->formatData(true, $this->dummy, 'enabled');
$false = self::$service->formatData(false, $this->dummy, 'enabled');
$this->assertNotSame($true, $false);
}
public function testIntegerFormatsToItsStringRepresentation(): void
{
$result = self::$service->formatData(42, $this->dummy, 'count');
$this->assertSame('42', $result);
}
public function testFloatFormatsToItsStringRepresentation(): void
{
$result = self::$service->formatData(3.14, $this->dummy, 'price');
$this->assertSame('3.14', $result);
}
public function testNullFormatsAsItalicNull(): void
{
$result = self::$service->formatData(null, $this->dummy, 'field');
$this->assertSame('<i>null</i>', $result);
}
public function testDateTimeArrayFormatsToDateString(): void
{
$data = [
'date' => '2024-01-15 10:30:00.000000',
'timezone_type' => 3,
'timezone' => 'UTC',
];
$result = self::$service->formatData($data, $this->dummy, 'created_at');
$this->assertIsString($result);
$this->assertNotEmpty($result);
// Should not be the JSON fallback
$this->assertStringNotContainsString('json-formatter', $result);
}
public function testPlainArrayFormatsAsJsonDiv(): void
{
$result = self::$service->formatData(['key' => 'value', 'num' => 1], $this->dummy, 'tags');
$this->assertStringContainsString('json-formatter', $result);
}
public function testUnsupportedTypeThrowsRuntimeException(): void
{
$this->expectException(\RuntimeException::class);
self::$service->formatData(new \stdClass(), $this->dummy, 'field');
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\LogSystem;
use App\Services\LogSystem\LogDiffFormatter;
use PHPUnit\Framework\TestCase;
final class LogDiffFormatterTest extends TestCase
{
private LogDiffFormatter $service;
protected function setUp(): void
{
$this->service = new LogDiffFormatter();
}
public function testPositiveNumericDiff(): void
{
$result = $this->service->formatDiff(1, 6);
$this->assertStringContainsString('text-success', $result);
$this->assertStringContainsString('+5', $result);
}
public function testNegativeNumericDiff(): void
{
$result = $this->service->formatDiff(10, 3);
$this->assertStringContainsString('text-danger', $result);
$this->assertStringContainsString('-7', $result);
}
public function testZeroNumericDiff(): void
{
$result = $this->service->formatDiff(5, 5);
$this->assertStringContainsString('text-muted', $result);
$this->assertStringContainsString('0', $result);
}
public function testStringDiffReturnsNonEmptyHtml(): void
{
$result = $this->service->formatDiff('hello world', 'hello PHP');
$this->assertNotEmpty($result);
// DiffHelper returns HTML
$this->assertStringContainsString('<', $result);
}
public function testUnsupportedTypesReturnEmptyString(): void
{
// booleans are neither string nor numeric → empty
$result = $this->service->formatDiff(true, false);
$this->assertSame('', $result);
}
public function testFloatDiff(): void
{
$result = $this->service->formatDiff(1.5, 3.0);
$this->assertStringContainsString('text-success', $result);
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\LogSystem;
use App\Entity\LogSystem\DatabaseUpdatedLogEntry;
use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\LogSystem\UserLogoutLogEntry;
use App\Services\LogSystem\LogEntryExtraFormatter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class LogEntryExtraFormatterTest extends WebTestCase
{
private static LogEntryExtraFormatter $service;
public static function setUpBeforeClass(): void
{
self::bootKernel();
self::$service = self::getContainer()->get(LogEntryExtraFormatter::class);
}
public function testFormatUserLoginLogEntryContainsIp(): void
{
$entry = new UserLoginLogEntry('127.0.0.1', anonymize: false);
$result = self::$service->format($entry);
$this->assertNotEmpty($result);
$this->assertStringContainsString('127.0.0.1', $result);
}
public function testFormatDatabaseUpdatedLogEntryContainsVersions(): void
{
$entry = new DatabaseUpdatedLogEntry('1.0.0', '2.0.0');
$result = self::$service->format($entry);
$this->assertStringContainsString('1.0.0', $result);
$this->assertStringContainsString('2.0.0', $result);
}
public function testFormatUserLogoutContainsIp(): void
{
$entry = new UserLogoutLogEntry('10.0.0.1', anonymize: false);
$result = self::$service->format($entry);
$this->assertNotEmpty($result);
$this->assertStringContainsString('10.0.0.1', $result);
}
public function testFormatConsoleReplacesHtmlTags(): void
{
$entry = new DatabaseUpdatedLogEntry('1.0', '2.0');
$result = self::$service->formatConsole($entry);
// Console format replaces the arrow icon with →
$this->assertStringContainsString('→', $result);
// No raw HTML tags should remain from the arrow icon
$this->assertStringNotContainsString('<i class="fas fa-long-arrow-alt-right"></i>', $result);
}
public function testFormatConsoleReturnsString(): void
{
$entry = new UserLoginLogEntry('192.168.1.1', anonymize: false);
$result = self::$service->formatConsole($entry);
$this->assertIsString($result);
$this->assertNotEmpty($result);
}
public function testIpAddressIsHtmlEscapedInFormat(): void
{
// Verify that the IP embedded in the result is safe (htmlspecialchars is applied)
$entry = new UserLoginLogEntry('192.168.0.1', anonymize: false);
$result = self::$service->format($entry);
// The result must not contain unescaped HTML even from a crafted IP
$this->assertStringNotContainsString('<script>', $result);
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\LogSystem;
use App\Services\LogSystem\LogLevelHelper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LogLevel;
final class LogLevelHelperTest extends TestCase
{
private LogLevelHelper $service;
protected function setUp(): void
{
$this->service = new LogLevelHelper();
}
public static function iconClassProvider(): \Generator
{
yield [LogLevel::DEBUG, 'fa-bug'];
yield [LogLevel::INFO, 'fa-info'];
yield [LogLevel::NOTICE, 'fa-flag'];
yield [LogLevel::WARNING, 'fa-exclamation-circle'];
yield [LogLevel::ERROR, 'fa-exclamation-triangle'];
yield [LogLevel::CRITICAL, 'fa-bolt'];
yield [LogLevel::ALERT, 'fa-radiation'];
yield [LogLevel::EMERGENCY, 'fa-skull-crossbones'];
}
#[DataProvider('iconClassProvider')]
public function testLogLevelToIconClass(string $logLevel, string $expectedIcon): void
{
$this->assertSame($expectedIcon, $this->service->logLevelToIconClass($logLevel));
}
public function testUnknownLogLevelReturnsDefaultIcon(): void
{
$this->assertSame('fa-question-circle', $this->service->logLevelToIconClass('unknown_level'));
}
public static function tableColorProvider(): \Generator
{
yield [LogLevel::EMERGENCY, 'table-danger'];
yield [LogLevel::ALERT, 'table-danger'];
yield [LogLevel::CRITICAL, 'table-danger'];
yield [LogLevel::ERROR, 'table-danger'];
yield [LogLevel::WARNING, 'table-warning'];
yield [LogLevel::NOTICE, 'table-info'];
yield [LogLevel::INFO, ''];
yield [LogLevel::DEBUG, ''];
}
#[DataProvider('tableColorProvider')]
public function testLogLevelToTableColorClass(string $logLevel, string $expectedClass): void
{
$this->assertSame($expectedClass, $this->service->logLevelToTableColorClass($logLevel));
}
public function testUnknownLogLevelReturnsEmptyColor(): void
{
$this->assertSame('', $this->service->logLevelToTableColorClass('unknown_level'));
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Services\Parts; namespace App\Tests\Services\Parts;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot; use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation; use App\Entity\Parts\StorageLocation;
@ -167,6 +168,223 @@ final class PartLotWithdrawAddHelperTest extends WebTestCase
$this->service->stocktake($this->partLot2, 0, "Test"); $this->service->stocktake($this->partLot2, 0, "Test");
$this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON); $this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON);
$this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared $this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared
}
// --- withdraw() error paths ---
public function testWithdrawZeroAmountThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->withdraw($this->partLot1, 0, "Test");
}
public function testWithdrawNegativeAmountThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->withdraw($this->partLot1, -5, "Test");
}
public function testWithdrawMoreThanStockThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->service->withdraw($this->partLot1, 999, "Test");
}
public function testWithdrawFromUnknownInstockLotThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->service->withdraw($this->lotWithUnknownInstock, 1, "Test");
}
// --- add() error paths ---
public function testAddZeroAmountThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->add($this->partLot1, 0, "Test");
}
public function testAddNegativeAmountThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->add($this->partLot1, -3, "Test");
}
public function testAddToFullLotThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->service->add($this->fullLot, 1, "Test");
}
public function testAddToUnknownInstockLotThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->service->add($this->lotWithUnknownInstock, 1, "Test");
}
// --- move() error paths ---
public function testMoveZeroAmountThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->move($this->partLot1, $this->partLot2, 0, "Test");
}
public function testMoveBetweenDifferentPartsThrows(): void
{
$otherPart = new Part();
$otherLot = new TestPartLot();
$otherLot->setPart($otherPart);
$otherLot->setAmount(5);
$this->expectException(\RuntimeException::class);
$this->service->move($this->partLot1, $otherLot, 5, "Test");
}
public function testMoveMoreThanOriginStockThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->service->move($this->partLot1, $this->partLot2, 999, "Test");
}
public function testMoveFromUnwithdrawableLotThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->service->move($this->lotWithUnknownInstock, $this->partLot2, 1, "Test");
}
public function testMoveToUnavailableLotThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->service->move($this->partLot1, $this->fullLot, 1, "Test");
}
// --- stocktake() error paths ---
public function testStocktakeNegativeAmountThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->stocktake($this->partLot1, -1, "Test");
}
// --- integer-rounding (useFloatAmount() = false, no unit set) ---
public function testWithdrawRoundsAmountForIntegerPart(): void
{
// No unit → useFloatAmount() = false → fractional amounts are rounded
$this->assertFalse($this->part->useFloatAmount());
$this->service->withdraw($this->partLot1, 1.7, "Test"); // rounds to 2
$this->assertEqualsWithDelta(8.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON);
}
public function testAddRoundsAmountForIntegerPart(): void
{
$this->assertFalse($this->part->useFloatAmount());
$this->service->add($this->partLot3, 1.7, "Test"); // rounds to 2
$this->assertEqualsWithDelta(2.0, $this->partLot3->getAmount(), PHP_FLOAT_EPSILON);
}
public function testStocktakeRoundsAmountForIntegerPart(): void
{
$this->assertFalse($this->part->useFloatAmount());
$this->service->stocktake($this->partLot1, 7.6, "Test"); // rounds to 8
$this->assertEqualsWithDelta(8.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON);
}
// --- float amounts are preserved when the unit allows floats ---
public function testAddPreservesFloatAmountForFloatUnit(): void
{
$unit = new MeasurementUnit();
$unit->setIsInteger(false);
$floatPart = new Part();
$floatPart->setPartUnit($unit);
$this->assertTrue($floatPart->useFloatAmount());
$lot = new TestPartLot();
$lot->setPart($floatPart);
$lot->setAmount(1.0);
$this->service->add($lot, 1.3, "Test");
$this->assertEqualsWithDelta(2.3, $lot->getAmount(), PHP_FLOAT_EPSILON);
}
public function testWithdrawPreservesFloatAmountForFloatUnit(): void
{
$unit = new MeasurementUnit();
$unit->setIsInteger(false);
$floatPart = new Part();
$floatPart->setPartUnit($unit);
$lot = new TestPartLot();
$lot->setPart($floatPart);
$lot->setAmount(5.0);
$this->service->withdraw($lot, 1.3, "Test");
$this->assertEqualsWithDelta(3.7, $lot->getAmount(), PHP_FLOAT_EPSILON);
}
// --- delete_lot_if_empty ---
/**
* Creates a PartLot that looks like a managed, persisted entity to Doctrine:
* - has a non-null ID (required by AbstractLogEntry when creating stock-change log entries)
* - is registered in the UnitOfWork as managed (required so EntityManager::remove() accepts it)
*/
private function makeManagedLot(float $amount, int $fakeId = 42): PartLot
{
$lot = new PartLot();
$lot->setPart($this->part);
$lot->setAmount($amount);
$ref = new \ReflectionProperty($lot, 'id');
$ref->setValue($lot, $fakeId);
$em = self::getContainer()->get('doctrine.orm.entity_manager');
$em->getUnitOfWork()->registerManaged($lot, ['id' => $fakeId], []);
return $lot;
}
public function testWithdrawDeletesLotWhenEmptyAndFlagSet(): void
{
$lot = $this->makeManagedLot(10);
$this->service->withdraw($lot, 10, "Test", null, true);
$this->assertEqualsWithDelta(0.0, $lot->getAmount(), PHP_FLOAT_EPSILON);
$em = self::getContainer()->get('doctrine.orm.entity_manager');
$scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions();
$this->assertContains($lot, $scheduled);
}
public function testWithdrawDoesNotDeleteLotWhenNotEmptyAndFlagSet(): void
{
$lot = $this->makeManagedLot(10);
$this->service->withdraw($lot, 5, "Test", null, true);
$this->assertEqualsWithDelta(5.0, $lot->getAmount(), PHP_FLOAT_EPSILON);
$em = self::getContainer()->get('doctrine.orm.entity_manager');
$scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions();
$this->assertNotContains($lot, $scheduled);
}
public function testMoveDeletesOriginLotWhenEmptyAndFlagSet(): void
{
$origin = $this->makeManagedLot(10, 43);
$target = $this->makeManagedLot(0, 44);
$this->service->move($origin, $target, 10, "Test", null, true);
$this->assertEqualsWithDelta(0.0, $origin->getAmount(), PHP_FLOAT_EPSILON);
$em = self::getContainer()->get('doctrine.orm.entity_manager');
$scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions();
$this->assertContains($origin, $scheduled);
} }
} }

View file

@ -59,4 +59,36 @@ final class PartsTableActionHandlerTest extends WebTestCase
} }
} }
public function testExportUrlContainsPartIds(): void
{
$part1 = $this->createMock(Part::class);
$part1->method('getId')->willReturn(42);
$part2 = $this->createMock(Part::class);
$part2->method('getId')->willReturn(99);
$result = $this->service->handleAction('export_csv', [$part1, $part2], 1, '/test');
$this->assertInstanceOf(RedirectResponse::class, $result);
// Commas in query-string values are not percent-encoded by Symfony's UrlGenerator
$this->assertStringContainsString('ids=42,99', $result->getTargetUrl());
}
public function testExportWithNoPartsProducesEmptyIds(): void
{
$result = $this->service->handleAction('export_json', [], 1, '/test');
$this->assertInstanceOf(RedirectResponse::class, $result);
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
// ids parameter present but empty
$this->assertStringContainsString('ids=', $result->getTargetUrl());
}
public function testUnknownActionWithEmptyPartsReturnsNull(): void
{
// The unknown-action switch only runs inside the foreach loop, so an
// empty parts list means the loop body never executes and no exception is thrown.
$result = $this->service->handleAction('unknown_action_xyz', [], null, '/test');
$this->assertNull($result);
}
} }

View file

@ -24,10 +24,12 @@ namespace App\Tests\Services\Parts;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail; use App\Entity\PriceInformations\Pricedetail;
use App\Services\Formatters\AmountFormatter; use App\Services\Formatters\AmountFormatter;
use App\Services\Parts\PricedetailHelper; use App\Services\Parts\PricedetailHelper;
use Brick\Math\BigDecimal;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class PricedetailHelperTest extends WebTestCase final class PricedetailHelperTest extends WebTestCase
@ -87,4 +89,181 @@ final class PricedetailHelperTest extends WebTestCase
{ {
$this->assertSame($expected_result, $this->service->getMaxDiscountAmount($part), $message); $this->assertSame($expected_result, $this->service->getMaxDiscountAmount($part), $message);
} }
// --- getMinOrderAmount ---
public static function minOrderAmountDataProvider(): \Generator
{
$part = new Part();
yield [$part, null, 'No orderdetails'];
$part = new Part();
$part->addOrderdetail(new Orderdetail()); // orderdetail with no pricedetails
yield [$part, null, 'Empty orderdetail'];
$part = new Part();
$od = new Orderdetail();
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(5));
$part->addOrderdetail($od);
yield [$part, 5.0, 'Single pricedetail'];
// The service reads $pricedetails[0] assuming the collection is sorted ascending
// (which Doctrine does automatically for persistent collections). For in-memory
// collections we must insert in ascending order ourselves.
$part = new Part();
$od = new Orderdetail();
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1));
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(3));
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(10));
$part->addOrderdetail($od);
yield [$part, 1.0, 'Multiple pricedetails — picks minimum (first in ascending order)'];
$part = new Part();
$od1 = new Orderdetail();
$od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(5));
$od2 = new Orderdetail();
$od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(2));
$part->addOrderdetail($od1);
$part->addOrderdetail($od2);
yield [$part, 2.0, 'Multiple orderdetails — picks global minimum'];
}
#[DataProvider('minOrderAmountDataProvider')]
public function testGetMinOrderAmount(Part $part, ?float $expected, string $message): void
{
$this->assertSame($expected, $this->service->getMinOrderAmount($part), $message);
}
// --- calculateAvgPrice ---
private static function makePartWithPrice(float $pricePerUnit, float $minQty = 1.0): Part
{
$part = new Part();
$od = new Orderdetail();
$pd = (new Pricedetail())
->setMinDiscountQuantity($minQty)
->setPrice(BigDecimal::of((string) $pricePerUnit));
$od->addPricedetail($pd);
$part->addOrderdetail($od);
return $part;
}
public function testCalculateAvgPriceNoOrderdetailsReturnsNull(): void
{
$this->assertNull($this->service->calculateAvgPrice(new Part()));
}
public function testCalculateAvgPriceExplicitAmount(): void
{
$part = self::makePartWithPrice(2.00);
$result = $this->service->calculateAvgPrice($part, 1.0);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('2.00000')->isEqualTo($result));
}
public function testCalculateAvgPriceUsesMinOrderAmountWhenAmountIsNull(): void
{
// Min order amount is 5; the price applies for qty >= 5
$part = self::makePartWithPrice(3.00, 5.0);
$result = $this->service->calculateAvgPrice($part, null);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('3.00000')->isEqualTo($result));
}
public function testCalculateAvgPriceAveragesMultipleSuppliers(): void
{
$part = new Part();
$od1 = new Orderdetail();
$od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('2.00')));
$part->addOrderdetail($od1);
$od2 = new Orderdetail();
$od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('4.00')));
$part->addOrderdetail($od2);
// Average of 2.00 and 4.00 = 3.00
$result = $this->service->calculateAvgPrice($part, 1.0);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('3.00000')->isEqualTo($result));
}
public function testCalculateAvgPriceSkipsSupplierWithNoCoverageForAmount(): void
{
// Only one supplier covers qty=1, the other requires qty >= 100
$part = new Part();
$od1 = new Orderdetail();
$od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('5.00')));
$part->addOrderdetail($od1);
$od2 = new Orderdetail();
$od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(100)->setPrice(BigDecimal::of('1.00')));
$part->addOrderdetail($od2);
$result = $this->service->calculateAvgPrice($part, 1.0);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('5.00000')->isEqualTo($result));
}
// --- convertMoneyToCurrency ---
public function testConvertMoneyToCurrencyIdentityBothNull(): void
{
// Both currencies null = base currency; same currency, no conversion
$value = BigDecimal::of('10.00');
$result = $this->service->convertMoneyToCurrency($value, null, null);
$this->assertNotNull($result);
$this->assertTrue($value->isEqualTo($result));
}
public function testConvertMoneyToCurrencyFromForeignToBase(): void
{
// EUR → base (null): exchange rate = 1.2 means 1 foreign = 1.2 base
$currency = new Currency();
$currency->setExchangeRate(BigDecimal::of('1.2'));
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null);
$this->assertNotNull($result);
// 10 * 1.2 = 12
$this->assertTrue(BigDecimal::of('12.00000')->isEqualTo($result));
}
public function testConvertMoneyToCurrencyNullExchangeRateReturnsNull(): void
{
$currency = new Currency();
// exchange rate not set → null
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null);
$this->assertNull($result);
}
public function testConvertMoneyToCurrencyZeroExchangeRateReturnsNull(): void
{
$currency = new Currency();
$currency->setExchangeRate(BigDecimal::zero());
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null);
$this->assertNull($result);
}
public function testConvertMoneyToCurrencyTargetNullExchangeRateReturnsNull(): void
{
$target = new Currency();
// exchange rate not set → getInverseExchangeRate() returns null
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), null, $target);
$this->assertNull($result);
}
public function testConvertMoneyToCurrencySameCurrencyInstanceIsIdentity(): void
{
$currency = new Currency();
$currency->setExchangeRate(BigDecimal::of('2.0'));
$value = BigDecimal::of('5.00');
// origin === target → no conversion at all
$result = $this->service->convertMoneyToCurrency($value, $currency, $currency);
$this->assertNotNull($result);
$this->assertTrue($value->isEqualTo($result));
}
} }

View file

@ -240,6 +240,132 @@ final class ProjectBuildHelperTest extends WebTestCase
$this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result)); $this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result));
} }
// --- unknown-instock lots are excluded from buildable count ---
public function testGetMaximumBuildableCountForBOMEntryExcludesUnknownInstockLots(): void
{
$part = new Part();
$lot = new PartLot();
$lot->setAmount(100);
$lot->setInstockUnknown(true); // this lot should be ignored
$part->addPartLot($lot);
$entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10);
// All stock is in an unknown-instock lot → effective amount = 0 → 0 builds
$this->assertSame(0, $this->service->getMaximumBuildableCountForBOMEntry($entry));
}
public function testGetMaximumBuildableCountMixedKnownAndUnknownLots(): void
{
$part = new Part();
$knownLot = new PartLot();
$knownLot->setAmount(30);
$unknownLot = new PartLot();
$unknownLot->setAmount(999);
$unknownLot->setInstockUnknown(true);
$part->addPartLot($knownLot);
$part->addPartLot($unknownLot);
$entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10);
// Only the 30 known parts count → floor(30/10) = 3
$this->assertSame(3, $this->service->getMaximumBuildableCountForBOMEntry($entry));
}
// --- project with only non-part BOM entries ---
public function testGetMaximumBuildableCountOnlyNonPartEntriesReturnsIntMax(): void
{
$project = new Project();
$project->addBomEntry((new ProjectBOMEntry())->setName('Solder')->setQuantity(1));
$project->addBomEntry((new ProjectBOMEntry())->setName('Wire')->setQuantity(2));
// No part entries → nothing constrains the count → PHP_INT_MAX
$this->assertSame(PHP_INT_MAX, $this->service->getMaximumBuildableCount($project));
}
public function testGetMaximumBuildableCountAsStringOnlyNonPartEntries(): void
{
$project = new Project();
$project->addBomEntry((new ProjectBOMEntry())->setName('Solder')->setQuantity(1));
$this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project));
}
// --- isProjectBuildable ---
public function testIsProjectBuildable(): void
{
$project = new Project();
$part = new Part();
$lot = new PartLot();
$lot->setAmount(15);
$part->addPartLot($lot);
$project->addBomEntry((new ProjectBOMEntry())->setPart($part)->setQuantity(5));
$this->assertTrue($this->service->isProjectBuildable($project, 3)); // 15/5 = 3 ✓
$this->assertFalse($this->service->isProjectBuildable($project, 4)); // 4 > 3 ✗
}
// --- isBOMEntryBuildable ---
public function testIsBOMEntryBuildable(): void
{
$part = new Part();
$lot = new PartLot();
$lot->setAmount(20);
$part->addPartLot($lot);
$entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10);
$this->assertTrue($this->service->isBOMEntryBuildable($entry, 2)); // 20/10 = 2 ✓
$this->assertFalse($this->service->isBOMEntryBuildable($entry, 3)); // 3 > 2 ✗
}
// --- getNonBuildableProjectBomEntries ---
public function testGetNonBuildableProjectBomEntriesReturnsShortEntries(): void
{
$project = new Project();
$abundantPart = new Part();
$lot1 = new PartLot();
$lot1->setAmount(100);
$abundantPart->addPartLot($lot1);
$project->addBomEntry((new ProjectBOMEntry())->setPart($abundantPart)->setQuantity(5));
$scarcePart = new Part();
$lot2 = new PartLot();
$lot2->setAmount(3);
$scarcePart->addPartLot($lot2);
$scarceEntry = (new ProjectBOMEntry())->setPart($scarcePart)->setQuantity(10);
$project->addBomEntry($scarceEntry);
// For 1 build: abundantPart OK (100 >= 5), scarcePart not (3 < 10)
$nonBuildable = $this->service->getNonBuildableProjectBomEntries($project, 1);
$this->assertCount(1, $nonBuildable);
$this->assertSame($scarceEntry, $nonBuildable[0]);
}
public function testGetNonBuildableProjectBomEntriesSkipsNonPartEntries(): void
{
$project = new Project();
$project->addBomEntry((new ProjectBOMEntry())->setName('Wire')->setQuantity(5));
// Non-part entries are ignored → no non-buildable entries
$this->assertCount(0, $this->service->getNonBuildableProjectBomEntries($project, 1));
}
public function testGetNonBuildableProjectBomEntriesThrowsOnZeroBuilds(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->getNonBuildableProjectBomEntries(new Project(), 0);
}
public function testCalculateTotalBuildPriceMixedEntries(): void public function testCalculateTotalBuildPriceMixedEntries(): void
{ {
$project = new Project(); $project = new Project();

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\UserSystem;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\PermissionPresetsHelper;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class PermissionPresetsHelperTest extends WebTestCase
{
private static PermissionPresetsHelper $service;
private static PermissionManager $permissionManager;
public static function setUpBeforeClass(): void
{
self::bootKernel();
self::$service = self::getContainer()->get(PermissionPresetsHelper::class);
self::$permissionManager = self::getContainer()->get(PermissionManager::class);
}
private function createUser(): User
{
return new User();
}
public function testAllInheritPresetLeavesAllPermissionsInherit(): void
{
$user = $this->createUser();
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_INHERIT);
// After all-inherit preset, 'parts' read should be null (inherit)
$this->assertNull(self::$permissionManager->dontInherit($user, 'parts', 'read'));
}
public function testAllForbidPresetSetsAllPermissionsToFalse(): void
{
$user = $this->createUser();
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_FORBID);
// After all-forbid, 'parts' read should be false (disallowed)
$this->assertFalse(self::$permissionManager->dontInherit($user, 'parts', 'read'));
}
public function testAllAllowPresetSetsAllPermissionsToTrue(): void
{
$user = $this->createUser();
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_ALLOW);
// After all-allow, 'parts' read should be true (allowed)
$this->assertTrue(self::$permissionManager->dontInherit($user, 'parts', 'read'));
}
public function testReadOnlyPresetAllowsPartsRead(): void
{
$user = $this->createUser();
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_READ_ONLY);
$this->assertTrue(self::$permissionManager->dontInherit($user, 'parts', 'read'));
}
public function testReadOnlyPresetDoesNotAllowPartsCreate(): void
{
$user = $this->createUser();
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_READ_ONLY);
// create should remain null (inherit) or false — not explicitly allowed
$createValue = self::$permissionManager->dontInherit($user, 'parts', 'create');
$this->assertNotTrue($createValue);
}
public function testUnknownPresetThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
self::$service->applyPreset($this->createUser(), 'non_existent_preset');
}
public function testApplyPresetReturnsTheSameUser(): void
{
$user = $this->createUser();
$returned = self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_INHERIT);
$this->assertSame($user, $returned);
}
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Validator\Constraints\BigDecimal;
use App\Validator\Constraints\BigDecimal\BigDecimalGreaterThanValidator;
use App\Validator\Constraints\BigDecimal\BigDecimalPositive;
use Brick\Math\BigDecimal;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* Tests BigDecimalGreaterThanValidator via the BigDecimalPositive constraint (value > 0).
*/
final class BigDecimalGreaterThanValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ConstraintValidatorInterface
{
return new BigDecimalGreaterThanValidator();
}
public function testNullIsValid(): void
{
$this->validator->validate(null, new BigDecimalPositive());
$this->assertNoViolation();
}
public function testPositiveIntegerIsValid(): void
{
$this->validator->validate(1, new BigDecimalPositive());
$this->assertNoViolation();
}
public function testPositiveStringIsValid(): void
{
$this->validator->validate('0.01', new BigDecimalPositive());
$this->assertNoViolation();
}
public function testPositiveBigDecimalIsValid(): void
{
$this->validator->validate(BigDecimal::of('1.5'), new BigDecimalPositive());
$this->assertNoViolation();
}
public function testZeroIsInvalid(): void
{
$constraint = new BigDecimalPositive();
$this->validator->validate(0, $constraint);
$this->buildViolation($constraint->message)
->setParameters(['{{ value }}' => '0', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
->setCode(\Symfony\Component\Validator\Constraints\GreaterThan::TOO_LOW_ERROR)
->assertRaised();
}
public function testZeroBigDecimalIsInvalid(): void
{
$constraint = new BigDecimalPositive();
$this->validator->validate(BigDecimal::of('0.00'), $constraint);
$this->buildViolation($constraint->message)
->setParameters(['{{ value }}' => '0.00', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
->setCode(\Symfony\Component\Validator\Constraints\GreaterThan::TOO_LOW_ERROR)
->assertRaised();
}
public function testNegativeIsInvalid(): void
{
$constraint = new BigDecimalPositive();
$this->validator->validate(-1, $constraint);
$this->buildViolation($constraint->message)
->setParameters(['{{ value }}' => '-1', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
->setCode(\Symfony\Component\Validator\Constraints\GreaterThan::TOO_LOW_ERROR)
->assertRaised();
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Validator\Constraints\BigDecimal;
use App\Validator\Constraints\BigDecimal\BigDecimalGreaterThenOrEqualValidator;
use App\Validator\Constraints\BigDecimal\BigDecimalPositiveOrZero;
use Brick\Math\BigDecimal;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* Tests BigDecimalGreaterThenOrEqualValidator via the BigDecimalPositiveOrZero constraint (value >= 0).
*/
final class BigDecimalGreaterThenOrEqualValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ConstraintValidatorInterface
{
return new BigDecimalGreaterThenOrEqualValidator();
}
public function testNullIsValid(): void
{
$this->validator->validate(null, new BigDecimalPositiveOrZero());
$this->assertNoViolation();
}
public function testPositiveIntegerIsValid(): void
{
$this->validator->validate(1, new BigDecimalPositiveOrZero());
$this->assertNoViolation();
}
public function testZeroIsValid(): void
{
$this->validator->validate(0, new BigDecimalPositiveOrZero());
$this->assertNoViolation();
}
public function testZeroBigDecimalIsValid(): void
{
$this->validator->validate(BigDecimal::of('0.00'), new BigDecimalPositiveOrZero());
$this->assertNoViolation();
}
public function testPositiveBigDecimalIsValid(): void
{
$this->validator->validate(BigDecimal::of('3.14'), new BigDecimalPositiveOrZero());
$this->assertNoViolation();
}
public function testNegativeIsInvalid(): void
{
$constraint = new BigDecimalPositiveOrZero();
$this->validator->validate(-1, $constraint);
$this->buildViolation($constraint->message)
->setParameters(['{{ value }}' => '-1', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
->setCode(\Symfony\Component\Validator\Constraints\GreaterThanOrEqual::TOO_LOW_ERROR)
->assertRaised();
}
public function testNegativeBigDecimalIsInvalid(): void
{
$constraint = new BigDecimalPositiveOrZero();
$this->validator->validate(BigDecimal::of('-0.01'), $constraint);
$this->buildViolation($constraint->message)
->setParameters(['{{ value }}' => '-0.01', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
->setCode(\Symfony\Component\Validator\Constraints\GreaterThanOrEqual::TOO_LOW_ERROR)
->assertRaised();
}
}

View file

@ -154,6 +154,33 @@ final class UniqueObjectCollectionValidatorTest extends ConstraintValidatorTestC
->assertRaised(); ->assertRaised();
} }
public function testThirdElementDuplicatePointsToIndexTwo(): void
{
// First two elements are unique; only the third duplicates the first.
$this->validator->validate(new ArrayCollection([
new DummyUniqueValidatableObject(['a' => 1]),
new DummyUniqueValidatableObject(['a' => 2]),
new DummyUniqueValidatableObject(['a' => 1]), // duplicate of index 0
]),
new UniqueObjectCollection(fields: ['a']));
$this
->buildViolation('This value is already used.')
->setCode(UniqueObjectCollection::IS_NOT_UNIQUE)
->setParameter('{{ object }}', 'objectString')
->atPath('property.path[2].a')
->assertRaised();
}
public function testAllNullsWithAllowNullProducesNoViolation(): void
{
$this->validator->validate(new ArrayCollection([
new DummyUniqueValidatableObject(['a' => null]),
new DummyUniqueValidatableObject(['a' => null]),
new DummyUniqueValidatableObject(['a' => null]),
]),
new UniqueObjectCollection(fields: ['a'], allowNull: true));
$this->assertNoViolation();
}
} }

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Validator\Constraints;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Part;
use App\Settings\MiscSettings\IpnSuggestSettings;
use App\Validator\Constraints\UniquePartIpnConstraint;
use App\Validator\Constraints\UniquePartIpnValidator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
final class UniquePartIpnValidatorTest extends ConstraintValidatorTestCase
{
private EntityManagerInterface&MockObject $em;
private IpnSuggestSettings&MockObject $ipnSettings;
protected function createValidator(): ConstraintValidatorInterface
{
$this->em = $this->createMock(EntityManagerInterface::class);
// createMock() bypasses the ForbidConstructorTrait; public properties are accessible directly
$this->ipnSettings = $this->createMock(IpnSuggestSettings::class);
$this->ipnSettings->autoAppendSuffix = false;
return new UniquePartIpnValidator($this->em, $this->ipnSettings);
}
public function testNullValueIsValid(): void
{
$this->validator->validate(null, new UniquePartIpnConstraint());
$this->assertNoViolation();
}
public function testEmptyStringIsValid(): void
{
$this->validator->validate('', new UniquePartIpnConstraint());
$this->assertNoViolation();
}
public function testAutoAppendSuffixSkipsValidation(): void
{
$this->ipnSettings->autoAppendSuffix = true;
$this->validator->validate('IPN-001', new UniquePartIpnConstraint());
$this->assertNoViolation();
}
public function testUniqueIpnIsValid(): void
{
$repo = $this->createMock(\Doctrine\ORM\EntityRepository::class);
$repo->method('findBy')->willReturn([]);
$this->em->method('getRepository')->willReturn($repo);
$part = new Part();
$this->setObject($part);
$this->validator->validate('UNIQUE-IPN', new UniquePartIpnConstraint());
$this->assertNoViolation();
}
public function testDuplicateIpnRaisesViolation(): void
{
$existingPart = new Part();
$ref = new \ReflectionProperty(AbstractDBElement::class, 'id');
$ref->setValue($existingPart, 99);
$repo = $this->createMock(\Doctrine\ORM\EntityRepository::class);
$repo->method('findBy')->willReturn([$existingPart]);
$this->em->method('getRepository')->willReturn($repo);
// Validated part has no ID (new, unsaved part)
$part = new Part();
$this->setObject($part);
$constraint = new UniquePartIpnConstraint();
$this->validator->validate('DUPLICATE-IPN', $constraint);
$this->buildViolation($constraint->message)
->setParameter('{{ value }}', 'DUPLICATE-IPN')
->assertRaised();
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Validator\Constraints;
use App\Validator\Constraints\ValidFileFilter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class ValidFileFilterValidatorTest extends WebTestCase
{
private static ValidatorInterface $validator;
public static function setUpBeforeClass(): void
{
self::bootKernel();
self::$validator = self::getContainer()->get('validator');
}
public function testNullIsValid(): void
{
$violations = self::$validator->validate(null, new ValidFileFilter());
$this->assertCount(0, $violations);
}
public function testEmptyStringIsValid(): void
{
$violations = self::$validator->validate('', new ValidFileFilter());
$this->assertCount(0, $violations);
}
public function testValidExtensionFilterIsValid(): void
{
$violations = self::$validator->validate('.jpg,.png', new ValidFileFilter());
$this->assertCount(0, $violations);
}
public function testValidMimeTypeFilterIsValid(): void
{
$violations = self::$validator->validate('image/*', new ValidFileFilter());
$this->assertCount(0, $violations);
}
public function testMixedValidFilterIsValid(): void
{
$violations = self::$validator->validate('image/*, .pdf, video/mp4', new ValidFileFilter());
$this->assertCount(0, $violations);
}
public function testInvalidFilterRaisesViolation(): void
{
$violations = self::$validator->validate('*.notvalid', new ValidFileFilter());
$this->assertCount(1, $violations);
}
public function testFullFilenameRaisesViolation(): void
{
$violations = self::$validator->validate('test.png', new ValidFileFilter());
$this->assertCount(1, $violations);
}
}

View file

@ -24,52 +24,54 @@ namespace App\Tests\Validator\Constraints;
use App\Validator\Constraints\ValidGTIN; use App\Validator\Constraints\ValidGTIN;
use App\Validator\Constraints\ValidGTINValidator; use App\Validator\Constraints\ValidGTINValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
final class ValidGTINValidatorTest extends ConstraintValidatorTestCase final class ValidGTINValidatorTest extends ConstraintValidatorTestCase
{ {
public function testAllowNull(): void
{
$this->validator->validate(null, new ValidGTIN());
$this->assertNoViolation();
}
public function testValidGTIN8(): void
{
$this->validator->validate('12345670', new ValidGTIN());
$this->assertNoViolation();
}
public function testValidGTIN12(): void
{
$this->validator->validate('123456789012', new ValidGTIN());
$this->assertNoViolation();
}
public function testValidGTIN13(): void
{
$this->validator->validate('1234567890128', new ValidGTIN());
$this->assertNoViolation();
}
public function testValidGTIN14(): void
{
$this->validator->validate('12345678901231', new ValidGTIN());
$this->assertNoViolation();
}
public function testInvalidGTIN(): void
{
$this->validator->validate('1234567890123', new ValidGTIN());
$this->buildViolation('validator.invalid_gtin')
->assertRaised();
}
protected function createValidator(): ConstraintValidatorInterface protected function createValidator(): ConstraintValidatorInterface
{ {
return new ValidGTINValidator(); return new ValidGTINValidator();
} }
// --- values that must produce no violation ---
public static function validValuesProvider(): \Generator
{
yield 'null is skipped' => [null];
yield 'empty string is skipped' => [''];
yield 'valid GTIN-8' => ['12345670'];
yield 'valid GTIN-12' => ['123456789012'];
yield 'valid GTIN-13' => ['1234567890128'];
yield 'valid GTIN-14' => ['12345678901231'];
}
#[DataProvider('validValuesProvider')]
public function testValidValue(mixed $value): void
{
$this->validator->validate($value, new ValidGTIN());
$this->assertNoViolation();
}
// --- values that must produce a violation ---
public static function invalidValuesProvider(): \Generator
{
yield 'wrong check digit (GTIN-13)' => ['1234567890123'];
yield 'non-numeric string' => ['ABCDEFGHIJKLM'];
yield 'wrong length — 9 digits' => ['123456789'];
yield 'wrong length — 11 digits' => ['12345678901'];
yield 'leading whitespace' => [' 1234567890128'];
yield 'trailing whitespace' => ['1234567890128 '];
}
#[DataProvider('invalidValuesProvider')]
public function testInvalidValue(string $value): void
{
$this->validator->validate($value, new ValidGTIN());
$this->buildViolation('validator.invalid_gtin')
->assertRaised();
}
} }

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Validator\Constraints;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Validator\Constraints\ValidPartLot;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class ValidPartLotValidatorTest extends WebTestCase
{
private static ValidatorInterface $validator;
public static function setUpBeforeClass(): void
{
self::bootKernel();
self::$validator = self::getContainer()->get('validator');
}
public function testPartLotWithoutStorageLocationIsValid(): void
{
$lot = new PartLot();
$lot->setPart(new Part());
// No storage location set → validation should pass without any location checks
$violations = self::$validator->validate($lot, new ValidPartLot());
$this->assertCount(0, $violations);
}
public function testPartLotWithNonFullNonRestrictedStorageLocationIsValid(): void
{
$lot = new PartLot();
$lot->setPart(new Part());
$location = new StorageLocation();
// Default: not full, not limited — should be valid
$lot->setStorageLocation($location);
$violations = self::$validator->validate($lot, new ValidPartLot());
$this->assertCount(0, $violations);
}
public function testPartLotWithFullLocationAndNewLotRaisesViolation(): void
{
$lot = new PartLot();
$lot->setPart(new Part());
$location = new StorageLocation();
$location->setIsFull(true);
$lot->setStorageLocation($location);
// The lot has no ID (new entity), so "parts" is empty, and a full location will reject it
$violations = self::$validator->validate($lot, new ValidPartLot());
// Should raise a violation because the location is full and the part is not in the existing parts list
$this->assertGreaterThan(0, count($violations));
}
public function testNonPartLotValueThrowsException(): void
{
$this->expectException(\Symfony\Component\Form\Exception\UnexpectedTypeException::class);
self::$validator->validate('not a part lot', new ValidPartLot());
}
public function testPartLotWithFullLocationRaisesNamedViolation(): void
{
$lot = new PartLot();
$lot->setPart(new Part());
$location = new StorageLocation();
$location->setIsFull(true);
$lot->setStorageLocation($location);
$violations = self::$validator->validate($lot, new ValidPartLot());
// Expect exactly one violation on the storage_location path
$this->assertCount(1, $violations);
$this->assertSame('storage_location', $violations[0]->getPropertyPath());
$this->assertStringContainsString('location_full', $violations[0]->getMessageTemplate());
}
public function testLimitToExistingPartsWithNewLotRaisesViolation(): void
{
$lot = new PartLot();
$lot->setPart(new Part());
$location = new StorageLocation();
$location->setLimitToExistingParts(true);
$lot->setStorageLocation($location);
// New lot (no ID) → parts collection is empty → part is not in the list → violation
$violations = self::$validator->validate($lot, new ValidPartLot());
$this->assertCount(1, $violations);
$this->assertSame('storage_location', $violations[0]->getPropertyPath());
$this->assertSame('validator.part_lot.only_existing', $violations[0]->getMessageTemplate());
}
// NOTE: The 'location_full.no_increase' violation (raised when a lot's amount
// is increased while its storage location is marked full) requires the entity to
// carry a real Doctrine originalEntityData snapshot, which is only set after an
// actual persist+flush. Testing that path belongs in a database integration test.
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Validator\Constraints;
use App\Validator\Constraints\Year2038BugWorkaround;
use App\Validator\Constraints\Year2038BugWorkaroundValidator;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
final class Year2038BugWorkaroundValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ConstraintValidatorInterface
{
// Disable validation by default so tests run on both 32- and 64-bit systems
return new Year2038BugWorkaroundValidator(disable_validation: true);
}
public function testIsNotActivatedWhenDisabled(): void
{
$validator = new Year2038BugWorkaroundValidator(disable_validation: true);
$this->assertFalse($validator->isActivated());
}
public function testIsNotActivatedOn64Bit(): void
{
// On any normal 64-bit CI/dev system PHP_INT_SIZE === 8, so activation requires 32-bit
if (PHP_INT_SIZE !== 8) {
$this->markTestSkipped('This test is only meaningful on 64-bit systems.');
}
$validator = new Year2038BugWorkaroundValidator(disable_validation: false);
$this->assertFalse($validator->isActivated());
}
public function testNullValueProducesNoViolation(): void
{
$this->validator->validate(null, new Year2038BugWorkaround());
$this->assertNoViolation();
}
public function testDateBefore2038ProducesNoViolationWhenDisabled(): void
{
$this->validator->validate(new \DateTime('2037-01-01'), new Year2038BugWorkaround());
$this->assertNoViolation();
}
public function testDateAfter2038ProducesNoViolationWhenDisabled(): void
{
// Validation disabled → even a "bad" date causes no violation
$this->validator->validate(new \DateTime('2039-01-01'), new Year2038BugWorkaround());
$this->assertNoViolation();
}
}

160
yarn.lock
View file

@ -529,10 +529,10 @@
"@babel/helper-module-transforms" "^7.28.6" "@babel/helper-module-transforms" "^7.28.6"
"@babel/helper-plugin-utils" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6"
"@babel/plugin-transform-modules-systemjs@^7.29.0": "@babel/plugin-transform-modules-systemjs@^7.29.4":
version "7.29.0" version "7.29.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz#e458a95a17807c415924106a3ff188a3b8dee964" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20"
integrity sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ== integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==
dependencies: dependencies:
"@babel/helper-module-transforms" "^7.28.6" "@babel/helper-module-transforms" "^7.28.6"
"@babel/helper-plugin-utils" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6"
@ -731,9 +731,9 @@
"@babel/helper-plugin-utils" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6"
"@babel/preset-env@^7.19.4": "@babel/preset-env@^7.19.4":
version "7.29.3" version "7.29.5"
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.3.tgz#2bbd5b0162e6a762adfe356f4aecdef837a3d574" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.5.tgz#c48b7ed94582c8b685e21b8b42de8633ec289268"
integrity sha512-ySZypNLAIH1ClygLDQzVMoGQRViATnkHkYYV6TcNDz+8+jwZCdsguGvsb3EY5d9wyWyhmF1iSuFM0Yh5XPnqSA== integrity sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==
dependencies: dependencies:
"@babel/compat-data" "^7.29.3" "@babel/compat-data" "^7.29.3"
"@babel/helper-compilation-targets" "^7.28.6" "@babel/helper-compilation-targets" "^7.28.6"
@ -774,7 +774,7 @@
"@babel/plugin-transform-member-expression-literals" "^7.27.1" "@babel/plugin-transform-member-expression-literals" "^7.27.1"
"@babel/plugin-transform-modules-amd" "^7.27.1" "@babel/plugin-transform-modules-amd" "^7.27.1"
"@babel/plugin-transform-modules-commonjs" "^7.28.6" "@babel/plugin-transform-modules-commonjs" "^7.28.6"
"@babel/plugin-transform-modules-systemjs" "^7.29.0" "@babel/plugin-transform-modules-systemjs" "^7.29.4"
"@babel/plugin-transform-modules-umd" "^7.27.1" "@babel/plugin-transform-modules-umd" "^7.27.1"
"@babel/plugin-transform-named-capturing-groups-regex" "^7.29.0" "@babel/plugin-transform-named-capturing-groups-regex" "^7.29.0"
"@babel/plugin-transform-new-target" "^7.27.1" "@babel/plugin-transform-new-target" "^7.27.1"
@ -1644,28 +1644,28 @@
resolved "https://registry.yarnpkg.com/@jbtronics/bs-treeview/-/bs-treeview-1.0.7.tgz#42a5ea40ce1bfe6cffbc1b811dc4e32dd8d0273a" resolved "https://registry.yarnpkg.com/@jbtronics/bs-treeview/-/bs-treeview-1.0.7.tgz#42a5ea40ce1bfe6cffbc1b811dc4e32dd8d0273a"
integrity sha512-AvEdkQNkNvh9+yGGHto8ABBsicEzFjLtSSbl61c9D0yq+RrIsrwTpz/H3RmDhvdtdteywQRItVuS18XOc+0p2A== integrity sha512-AvEdkQNkNvh9+yGGHto8ABBsicEzFjLtSSbl61c9D0yq+RrIsrwTpz/H3RmDhvdtdteywQRItVuS18XOc+0p2A==
"@jest/pattern@30.0.1": "@jest/pattern@30.4.0":
version "30.0.1" version "30.4.0"
resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.4.0.tgz#fcb519eeacc25caa3768f787595a27afa15302ae"
integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== integrity sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
jest-regex-util "30.0.1" jest-regex-util "30.4.0"
"@jest/schemas@30.0.5": "@jest/schemas@30.4.1":
version "30.0.5" version "30.4.1"
resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.4.1.tgz#c3703fdd71357e2c83aa59bd38469e60a11529c6"
integrity sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA== integrity sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==
dependencies: dependencies:
"@sinclair/typebox" "^0.34.0" "@sinclair/typebox" "^0.34.0"
"@jest/types@30.3.0": "@jest/types@30.4.1":
version "30.3.0" version "30.4.1"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.3.0.tgz#cada800d323cb74945c24ac74615fdb312a6c85f" resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.4.1.tgz#f79b647a85cb2ff4a90cc55984b31dae820db1f7"
integrity sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw== integrity sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==
dependencies: dependencies:
"@jest/pattern" "30.0.1" "@jest/pattern" "30.4.0"
"@jest/schemas" "30.0.5" "@jest/schemas" "30.4.1"
"@types/istanbul-lib-coverage" "^2.0.6" "@types/istanbul-lib-coverage" "^2.0.6"
"@types/istanbul-reports" "^3.0.4" "@types/istanbul-reports" "^3.0.4"
"@types/node" "*" "@types/node" "*"
@ -1854,9 +1854,9 @@
"@types/json-schema" "*" "@types/json-schema" "*"
"@types/estree@*", "@types/estree@^1.0.8": "@types/estree@*", "@types/estree@^1.0.8":
version "1.0.8" version "1.0.9"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
"@types/hast@3.0.4", "@types/hast@^3.0.0": "@types/hast@3.0.4", "@types/hast@^3.0.0":
version "3.0.4" version "3.0.4"
@ -1902,9 +1902,9 @@
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
"@types/node@*": "@types/node@*":
version "25.6.0" version "25.6.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.2.tgz#8c491201373690e4ef2a2ffed0dfb510a5830b92"
integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== integrity sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==
dependencies: dependencies:
undici-types "~7.19.0" undici-types "~7.19.0"
@ -1936,9 +1936,9 @@
"@types/yargs-parser" "*" "@types/yargs-parser" "*"
"@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.3.0": "@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.3.0":
version "1.3.0" version "1.3.1"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz#0e8f34854df7966b09304a18e808b23997bb9fc1"
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== integrity sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==
"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1":
version "1.14.1" version "1.14.1"
@ -2238,9 +2238,9 @@ base64-js@^1.1.2, base64-js@^1.3.0:
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.12: baseline-browser-mapping@^2.10.12:
version "2.10.27" version "2.10.29"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3" resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz#47bdc13027af28d341f367a4f35a07ce872e27b4"
integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA== integrity sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==
big.js@^5.2.2: big.js@^5.2.2:
version "5.2.2" version "5.2.2"
@ -2325,9 +2325,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0" lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001782: caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001782:
version "1.0.30001791" version "1.0.30001792"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz#dfb93d85c40ad380c57123e72e10f3c575786b51" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5"
integrity sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ== integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==
ccount@^2.0.0: ccount@^2.0.0:
version "2.0.1" version "2.0.1"
@ -2917,9 +2917,9 @@ domutils@^3.0.1:
domhandler "^5.0.3" domhandler "^5.0.3"
electron-to-chromium@^1.5.328: electron-to-chromium@^1.5.328:
version "1.5.349" version "1.5.353"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz#9b9c6a6d84d1107557c18a9336099ce0ee890e5b" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz#01e8a8e25a0bf13e631106045f177d0568ca91c2"
integrity sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A== integrity sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==
emoji-regex@^8.0.0: emoji-regex@^8.0.0:
version "8.0.0" version "8.0.0"
@ -2932,9 +2932,9 @@ emojis-list@^3.0.0:
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
enhanced-resolve@^5.0.0, enhanced-resolve@^5.20.0: enhanced-resolve@^5.0.0, enhanced-resolve@^5.20.0:
version "5.21.0" version "5.21.3"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz#bb8e6fabaf74930de70e61397798750429e5b1ae" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz#fa7fed23679e9169dfb705b8e201924421c4414a"
integrity sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA== integrity sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==
dependencies: dependencies:
graceful-fs "^4.2.4" graceful-fs "^4.2.4"
tapable "^2.3.3" tapable "^2.3.3"
@ -3039,9 +3039,9 @@ fast-deep-equal@^3.1.3:
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-uri@^3.0.1: fast-uri@^3.0.1:
version "3.1.1" version "3.1.2"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.1.tgz#dd085fec2494a2a33bac6e61277374669e1dd774" resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec"
integrity sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ== integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==
fastest-levenshtein@1.0.16, fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16: fastest-levenshtein@1.0.16, fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16:
version "1.0.16" version "1.0.16"
@ -3138,7 +3138,7 @@ has-flag@^4.0.0:
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
hasown@^2.0.2: hasown@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c"
integrity sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg== integrity sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==
@ -3350,11 +3350,11 @@ intl-messageformat@^10.5.11:
tslib "^2.8.0" tslib "^2.8.0"
is-core-module@^2.16.1: is-core-module@^2.16.1:
version "2.16.1" version "2.16.2"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082"
integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==
dependencies: dependencies:
hasown "^2.0.2" hasown "^2.0.3"
is-docker@^2.0.0: is-docker@^2.0.0:
version "2.2.1" version "2.2.1"
@ -3405,17 +3405,17 @@ isobject@^3.0.1:
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
jest-regex-util@30.0.1: jest-regex-util@30.4.0:
version "30.0.1" version "30.4.0"
resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.4.0.tgz#f75ccc43857633df2563a03588b5cb45c7c2941b"
integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== integrity sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==
jest-util@30.3.0: jest-util@30.4.1:
version "30.3.0" version "30.4.1"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.3.0.tgz#95a4fbacf2dac20e768e2f1744b70519f2ba7980" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.4.1.tgz#979c9d014fdd12bb95d3dcde0192e1a9e0bc93d6"
integrity sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg== integrity sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==
dependencies: dependencies:
"@jest/types" "30.3.0" "@jest/types" "30.4.1"
"@types/node" "*" "@types/node" "*"
chalk "^4.1.2" chalk "^4.1.2"
ci-info "^4.2.0" ci-info "^4.2.0"
@ -3432,13 +3432,13 @@ jest-worker@^27.4.5:
supports-color "^8.0.0" supports-color "^8.0.0"
jest-worker@^30.0.5: jest-worker@^30.0.5:
version "30.3.0" version "30.4.1"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.3.0.tgz#ae4dc1f1d93d0cba1415624fcedaec40ea764f14" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.4.1.tgz#ac010eb6c512425748a39e2d6bf05b2c4866ca4f"
integrity sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ== integrity sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@ungap/structured-clone" "^1.3.0" "@ungap/structured-clone" "^1.3.0"
jest-util "30.3.0" jest-util "30.4.1"
merge-stream "^2.0.0" merge-stream "^2.0.0"
supports-color "^8.1.1" supports-color "^8.1.1"
@ -4181,9 +4181,9 @@ pdfkit@^0.18.0:
png-js "^1.0.0" png-js "^1.0.0"
pdfmake@^0.3.7: pdfmake@^0.3.7:
version "0.3.7" version "0.3.8"
resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.3.7.tgz#7db4f5d83306d344cda20afdd59cd09cf4acdae1" resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.3.8.tgz#cebff884636fddda02af04599530355aa855131f"
integrity sha512-SwTFcaH3kCJBlPFWi/YB34zRg6lpCxq90tkZ9GxfSi9/v4Tk96cv4IvOstA+CC40rdW1OzQIuNhD2DLD1RDVgA== integrity sha512-ywj3MESfqOW7sOjXZiBKaWk7XLncZ9caMflM3WSbc0Do8Wpwn9DBV8ceKZqkz1M/avl8i+ccS2f8THZRyFaCGQ==
dependencies: dependencies:
linebreak "^1.1.0" linebreak "^1.1.0"
pdfkit "^0.18.0" pdfkit "^0.18.0"
@ -4748,9 +4748,9 @@ semver@^6.3.1:
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.2, semver@^7.3.4, semver@^7.6.3: semver@^7.3.2, semver@^7.3.4, semver@^7.6.3:
version "7.7.4" version "7.8.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== integrity sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==
serialize-javascript@^6.0.2: serialize-javascript@^6.0.2:
version "6.0.2" version "6.0.2"
@ -4932,9 +4932,9 @@ tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A== integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
terser-webpack-plugin@^5.3.0, terser-webpack-plugin@^5.3.17: terser-webpack-plugin@^5.3.0, terser-webpack-plugin@^5.3.17:
version "5.5.0" version "5.6.0"
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz#d92b8e2c892dd09c683c38120394267e8d8660ef" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3"
integrity sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA== integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==
dependencies: dependencies:
"@jridgewell/trace-mapping" "^0.3.25" "@jridgewell/trace-mapping" "^0.3.25"
jest-worker "^27.4.5" jest-worker "^27.4.5"
@ -4942,9 +4942,9 @@ terser-webpack-plugin@^5.3.0, terser-webpack-plugin@^5.3.17:
terser "^5.31.1" terser "^5.31.1"
terser@^5.31.1: terser@^5.31.1:
version "5.46.2" version "5.47.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.2.tgz#b9529672d5b0024c7959571c83b82f65077b2a4f" resolved "https://registry.yarnpkg.com/terser/-/terser-5.47.1.tgz#99b298e51bc41214304847de1429ec92fd1f7648"
integrity sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw== integrity sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==
dependencies: dependencies:
"@jridgewell/source-map" "^0.3.3" "@jridgewell/source-map" "^0.3.3"
acorn "^8.15.0" acorn "^8.15.0"
@ -4974,9 +4974,9 @@ to-regex-range@^5.0.1:
is-number "^7.0.0" is-number "^7.0.0"
tom-select@^2.1.0: tom-select@^2.1.0:
version "2.6.0" version "2.6.1"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.6.0.tgz#8582363389dd17157ed11692320530bcd4111fbf" resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.6.1.tgz#54be5c4431d5d59c8c4897e6e051963bac11f44a"
integrity sha512-o2ToBjhUAnrrQvW/hrY9c//TpOpAKYSlfuFnf0DIwNy+ua+mmYnsF4PxN/PpzBfUIfEFkNYAngeGBfOAZWF3tw== integrity sha512-d/1kngVOQTGcI/2pVDfDLYjtjUgSSd3fSgkYUpi0y+yRtQQu2kzljj3aUdqMfqc45cjPvDEpfDt/hSX4awDFTg==
dependencies: dependencies:
"@orchidjs/sifter" "^1.1.0" "@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.1.2" "@orchidjs/unicode-variants" "^1.1.2"