Compare commits

..

15 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
Jan Böhmer
cb669ad4ec Fixed phpstan issues
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-06 00:08:14 +02:00
Jan Böhmer
2e8ab8190a Bumped to version 2.11.1 2026-05-05 23:53:03 +02:00
Jan Böhmer
98c978ff1b Improved RandomizeUseragentHttpClient by not using old user agent strings, but different modernn profiles where also other headers match the user agent 2026-05-05 23:52:14 +02:00
Jan Böhmer
38779740ec AIWebProvider: Make URLs absolute before passing them to the LLM
This ensures that the URLs are valid afterwards, because the LLM does not know the base tag
2026-05-05 23:19:56 +02:00
Jan Böhmer
9c6f9a25c5 Use new watchtower image in configuation example
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
Fixes issue #1363
2026-05-05 22:41:38 +02:00
40 changed files with 4861 additions and 1847 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)
![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)

View file

@ -1 +1 @@
2.11.0
2.11.1

479
composer.lock generated

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -29,60 +29,137 @@ use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/**
* HttpClient wrapper that randomizes the user agent for each request, to make it harder for servers to detect and block us.
* It also sets some other headers to make the requests look more like real browser requests.
* When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent, until we run out of retries.
*/
final class RandomizeUseragentHttpClient implements HttpClientInterface
{
public const USER_AGENTS = [
"Mozilla/5.0 (Windows; U; Windows NT 10.0; Win64; x64) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/52.0.1359.302 Safari/600.6 Edge/15.25690",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_8_3) Gecko/20100101 Firefox/51.6",
"Mozilla/5.0 (Android; Android 4.4.4; E:number:20-23:00 Build/24.0.B.1.34) AppleWebKit/603.18 (KHTML, like Gecko) Chrome/47.0.1559.384 Mobile Safari/600.5",
"Mozilla/5.0 (compatible; MSIE 9.0; Windows; Windows NT 6.3; WOW64 Trident/5.0)",
"Mozilla/5.0 (Windows; Windows NT 6.0; Win64; x64) AppleWebKit/602.21 (KHTML, like Gecko) Chrome/51.0.3187.154 Safari/536",
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_4_2; like Mac OS X) AppleWebKit/537.24 (KHTML, like Gecko) Chrome/51.0.2432.275 Mobile Safari/535.6",
"Mozilla/5.0 (U; Linux i680 ) Gecko/20100101 Firefox/57.5",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 8_8_6; en-US) Gecko/20100101 Firefox/53.9",
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_6_7) AppleWebKit/534.46 (KHTML, like Gecko) Chrome/55.0.3276.345 Safari/535",
"Mozilla/5.0 (Windows; Windows NT 10.5;) AppleWebKit/535.42 (KHTML, like Gecko) Chrome/53.0.1176.353 Safari/534.0 Edge/11.95743",
"Mozilla/5.0 (Linux; Android 5.1.1; MOTO G Build/LPH223) AppleWebKit/600.27 (KHTML, like Gecko) Chrome/47.0.1604.204 Mobile Safari/535.1",
"Mozilla/5.0 (iPod; CPU iPod OS 7_4_8; like Mac OS X) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/50.0.1632.146 Mobile Safari/600.4",
"Mozilla/5.0 (Linux; U; Linux i570 ; en-US) Gecko/20100101 Firefox/49.9",
"Mozilla/5.0 (Windows NT 10.2; WOW64; en-US) AppleWebKit/603.2 (KHTML, like Gecko) Chrome/55.0.1299.311 Safari/535",
"Mozilla/5.0 (Windows; Windows NT 10.5; x64; en-US) AppleWebKit/603.39 (KHTML, like Gecko) Chrome/52.0.1443.139 Safari/536.6 Edge/13.79436",
"Mozilla/5.0 (Linux; U; Android 5.1; SM-G9350T Build/MMB29M) AppleWebKit/537.15 (KHTML, like Gecko) Chrome/55.0.2552.307 Mobile Safari/600.8",
"Mozilla/5.0 (Android; Android 6.0; SAMSUNG SM-D9350V Build/MDB08L) AppleWebKit/535.30 (KHTML, like Gecko) Chrome/53.0.1345.278 Mobile Safari/537.4",
"Mozilla/5.0 (Windows; Windows NT 10.0;) AppleWebKit/534.44 (KHTML, like Gecko) Chrome/47.0.3503.387 Safari/601",
private const PROFILES = [
// --- CHROME ON WINDOWS ---
'chrome_windows' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
'Sec-Ch-Ua-Mobile' => '?0',
'Sec-Ch-Ua-Platform' => '"Windows"',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
],
// --- CHROME ON MACOS ---
'chrome_mac' => [
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
'Sec-Ch-Ua' => '"Google Chrome";v="141", "Chromium";v="141", "Not=A?Brand";v="99"',
'Sec-Ch-Ua-Mobile' => '?0',
'Sec-Ch-Ua-Platform' => '"macOS"',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
],
// --- EDGE ON WINDOWS ---
'edge_windows' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0',
'Sec-Ch-Ua' => '"Microsoft Edge";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
'Sec-Ch-Ua-Mobile' => '?0',
'Sec-Ch-Ua-Platform' => '"Windows"',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
],
// --- FIREFOX ON WINDOWS ---
'firefox_windows' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
// Firefox does not send Sec-Ch-Ua headers by default
],
// --- FIREFOX ON LINUX ---
'firefox_linux' => [
'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
],
// --- SAFARI ON MACOS ---
'safari_mac' => [
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
],
// --- CHROME ON ANDROID (Mobile) ---
'chrome_android' => [
'User-Agent' => 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Mobile Safari/537.36',
'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
'Sec-Ch-Ua-Mobile' => '?1',
'Sec-Ch-Ua-Platform' => '"Android"',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
],
// --- SAFARI ON IPHONE (Mobile) ---
'safari_iphone' => [
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
],
];
private const COMMON_HEADERS = [
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language' => 'en-US,en;q=0.9',
'Sec-Fetch-Dest' => 'document',
'Sec-Fetch-Mode' => 'navigate',
'Sec-Fetch-Site' => 'none',
'Sec-Fetch-User' => '?1',
'Upgrade-Insecure-Requests' => '1',
];
private const ENTRY_REFERERS = [
'https://www.google.com/',
'https://www.bing.com/',
'https://duckduckgo.com/',
'https://t.co/', // Twitter/X shortener
'https://www.reddit.com/',
];
private ?string $lastUrl = null;
public function __construct(
private readonly HttpClientInterface $client,
private readonly array $userAgents = self::USER_AGENTS,
private readonly int $repeatOnFailure = 1,
) {
}
public function getRandomUserAgent(): string
{
return $this->userAgents[array_rand($this->userAgents)];
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$repeatsLeft = $this->repeatOnFailure;
do {
$modifiedOptions = $options;
if (!isset($modifiedOptions['headers']['User-Agent'])) {
$modifiedOptions['headers']['User-Agent'] = $this->getRandomUserAgent();
$profile = self::PROFILES[array_rand(self::PROFILES)];
// Merge common headers with the specific browser profile
$headers = array_merge(self::COMMON_HEADERS, $profile);
//Add a Referer header if not already set, to make it look more like a real browser request. We use the last URL we visited as the referer, to simulate internal navigation. If we don't have a last URL (first request), we pick a random entry point from common referers.
if (!isset($options['headers']['Referer'])) {
if ($this->lastUrl !== null) {
// If we have a previous URL, use it (Internal Navigation)
$headers['Referer'] = $this->lastUrl;
} else {
// First request? Pick an entry point (External Entry)
$headers['Referer'] = self::ENTRY_REFERERS[array_rand(self::ENTRY_REFERERS)];
}
}
$response = $this->client->request($method, $url, $modifiedOptions);
// Allow manual overrides from $options
$options['headers'] = array_merge($headers, $options['headers'] ?? []);
$response = $this->client->request($method, $url, $options);
//When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent
if (!in_array($response->getStatusCode(), [403, 429, 503], true)) {
$this->lastUrl = $url; // Update last visited URL for referer in the next request
return $response;
}
//Otherwise we try again with a different user agent, until we run out of retries
usleep(5000); // Sleep for 5ms to avoid hammering the server too hard in case of multiple retries
} while ($repeatsLeft-- > 0);
return $response;
@ -95,6 +172,6 @@ final class RandomizeUseragentHttpClient implements HttpClientInterface
public function withOptions(array $options): static
{
return new self($this->client->withOptions($options), $this->userAgents, $this->repeatOnFailure);
return new self($this->client->withOptions($options), $this->repeatOnFailure);
}
}

View file

@ -156,8 +156,8 @@ class AttachmentManager
//Taken from: https://www.php.net/manual/de/function.filesize.php#106569 and slightly modified
$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
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'];
if ($magnitude >= 0) {
$nearest = (int) floor(abs($magnitude) / 3);
$nearest = min((int) floor(abs($magnitude) / 3), count($prefixes_pos) - 1);
$symbol = $prefixes_pos[$nearest];
} else {
$nearest = (int) round(abs($magnitude) / 3);
$nearest = min((int) round(abs($magnitude) / 3), count($prefixes_neg) - 1);
$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)
//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'])) {
$attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1];
$attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1] ?? '';
} else {
//If the mime type is empty, we use the original extension
$attachment_row['extension'] = pathinfo((string) $attachment_row['originalname'], PATHINFO_EXTENSION);

View file

@ -39,6 +39,7 @@ use Psr\Cache\CacheItemPoolInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\UriResolver;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Component\Intl\Languages;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -146,7 +147,7 @@ final class AIWebProvider implements InfoProviderInterface
$html = $response->getContent();
//Convert html to markdown, to provide a cleaner input to the LLM.
$markdown = $this->htmlToMarkdown($html);
$markdown = $this->htmlToMarkdown($html, $url);
//Truncate markdown to max content length, if needed
$markdown = u($markdown)->truncate($this->settings->maxContentLength, '... [truncated]')->toString();
@ -182,10 +183,34 @@ final class AIWebProvider implements InfoProviderInterface
return json_encode($items->toObject(), JSON_THROW_ON_ERROR);
}
private function htmlToMarkdown(string $html): string
private function htmlToMarkdown(string $html, string $url): string
{
//Extract only the main content of the page to avoid overwhelming the LLM with irrelevant information.
$crawler = new Crawler($html);
//Replace relative URLs with absolute URLs, to ensure that the LLM has full context and can access the links if needed.
$baseUrl = $crawler->getBaseHref() ?? $url;
//Replace all relative links with their absolute counnterparts, to provide more context to the LLM and to ensure that any links included in the markdown are valid and can be accessed if needed.
$crawler->filter('a')->each(function (Crawler $node) use ($baseUrl) {
$href = $node->attr('href');
if ($href) {
$absoluteUrl = UriResolver::resolve($href, $baseUrl);
//@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements.
$node->getNode(0)->setAttribute('href', $absoluteUrl);
}
});
$crawler->filter('img')->each(function (Crawler $node) use ($baseUrl) {
$src = $node->attr('src');
if ($src) {
$absoluteUrl = UriResolver::resolve($src, $baseUrl);
//@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements.
$node->getNode(0)->setAttribute('src', $absoluteUrl);
}
});
//Extract only the main content of the page to avoid overwhelming the LLM with irrelevant information.
$mainContent = $crawler->filter('main, article, #content');
// If we found a specific content area, get its HTML; otherwise, use the whole body.
@ -198,7 +223,7 @@ final class AIWebProvider implements InfoProviderInterface
}
} else {
//Use the whole body content, as it might contain relevant information, especially for simpler pages that don't have a clear main/content section.
$htmlToConvert = $html;
$htmlToConvert = $crawler->outerHtml();
}

View file

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

View file

@ -296,17 +296,21 @@
<p>{% trans %}update_manager.docker.setup_description{% endtrans %}</p>
<h6>{% trans %}update_manager.docker.setup_step1{% endtrans %}</h6>
<pre class="bg-dark text-light p-3 rounded"><code>services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_HTTP_API_UPDATE=true
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
- WATCHTOWER_LABEL_ENABLE=true
ports:
- "8080:8080"</code></pre>
<pre class="bg-dark text-light p-3 rounded"><code>
# See documentation for full example: https://docs.part-db.de/installation/installation_docker.html
services:
watchtower:
image: ghcr.io/nicholas-fedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_HTTP_API_UPDATE=true
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
</code></pre>
<h6>{% trans %}update_manager.docker.setup_step2{% endtrans %}</h6>
<pre class="bg-dark text-light p-3 rounded"><code>WATCHTOWER_API_URL=http://watchtower:8080

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