Compare commits

..

No commits in common. "master" and "v2.11.1" have entirely different histories.

52 changed files with 1785 additions and 5676 deletions

View file

@ -1,3 +1,4 @@
[![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)

436
composer.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,139 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Services\InfoProviderSystem\SubmittedPageStorage;
use App\Services\InfoProviderSystem\DTOs\BrowserSubmittedPage;
use App\Settings\InfoProviderSystem\BrowserPluginSettings;
use App\Settings\SystemSettings\CustomizationSettings;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Provides the endpoint used by browser extensions to submit the current page's HTML to Part-DB,
* so that info providers can use it instead of fetching the URL themselves.
*/
#[Route('/tools/info_providers')]
class BrowserPluginController extends AbstractController
{
public function __construct(
private readonly SubmittedPageStorage $browserHtmlStorage,
private readonly ProviderRegistry $providerRegistry,
private readonly CustomizationSettings $customizationSettings,
private readonly BrowserPluginSettings $browserPluginSettings,
) {
}
private const URL_PROVIDER_KEYS = ['generic_web', 'ai_web'];
/**
* Returns instance info for the browser extension: logged-in username, instance name, and active URL providers.
*
* Response: { "username": "admin", "instance_name": "Part-DB", "url_providers": [{"id": "generic_web", "label": "Generic Web URL"}] }
*/
#[Route('/browser_info', name: 'browser_plugin_info', methods: ['GET'])]
public function getInfo(): JsonResponse
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
$this->throwIfDisabled();
$activeProviders = $this->providerRegistry->getActiveProviders();
$urlProviders = [];
foreach (self::URL_PROVIDER_KEYS as $key) {
if (isset($activeProviders[$key])) {
$urlProviders[] = [
'id' => $key,
'label' => $activeProviders[$key]->getProviderInfo()['name'],
];
}
}
$user = $this->getUser();
if ($user instanceof User) {
$username = $user->getFullName(true);
} else {
$username = $user ? $user->getUserIdentifier() : "unknown";
}
return new JsonResponse([
'username' => $username,
'instance_name' => $this->customizationSettings->instanceName,
'url_providers' => $urlProviders,
]);
}
/**
* Accepts a JSON POST body with the HTML of the current page from a browser extension.
* Stores the HTML in the session via BrowserHtmlSessionStorage and returns a redirect URL
* pointing to the standard part-creation flow with use_browser_html=1.
*
* Expected JSON body: { "html": "<full page HTML>", "url": "https://example.com/product", "provider": "generic_web" }
* The "provider" field is optional and defaults to "generic_web". Use "ai_web" for the AI extractor.
* Response: { "redirect_url": "https://partdb.example.com/en/part/from_info_provider/generic_web/https%3A%2F%2F.../create?use_browser_html=1&no_cache=1" }
*/
#[Route('/browser_html', name: 'browser_plugin_submit_html', methods: ['POST'])]
public function submitHtml(Request $request,
#[MapRequestPayload]
BrowserSubmittedPage $page
): JsonResponse
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
$this->throwIfDisabled();
$payload = $request->getPayload();
$provider = $payload->get('provider', null);
// The maprequestpayload already validates the URL and HTML content:
$token = $this->browserHtmlStorage->store($page);
if ($provider !== null) {
$redirectUrl = $this->generateUrl('info_providers_create_part', [
'providerKey' => $provider,
'providerId' => $page->url,
'submitted_page_token' => $token,
], UrlGeneratorInterface::ABSOLUTE_URL);
}
return new JsonResponse([
'redirect_url' => $redirectUrl ?? null,
]);
}
public function throwIfDisabled(): void
{
if (!$this->browserPluginSettings->enabled) {
throw HttpException::fromStatusCode(451, "Browser plugin feature is disabled by the administrator, ask him to enable it in system settings.");
}
}
}

View file

@ -28,7 +28,6 @@ use App\Entity\Parts\Part;
use App\Exceptions\OAuthReconnectRequiredException;
use App\Form\InfoProviderSystem\FromURLFormType;
use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\SubmittedPageStorage;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
use App\Services\InfoProviderSystem\PartInfoRetriever;
@ -63,8 +62,7 @@ class InfoProviderController extends AbstractController
private readonly PartInfoRetriever $infoRetriever,
private readonly ExistingPartFinder $existingPartFinder,
private readonly SettingsManagerInterface $settingsManager,
private readonly SettingsFormFactoryInterface $settingsFormFactory,
private readonly SubmittedPageStorage $browserHtmlStorage,
private readonly SettingsFormFactoryInterface $settingsFormFactory
)
{
@ -223,7 +221,7 @@ class InfoProviderController extends AbstractController
}
#[Route('/from_url', name: 'info_providers_from_url')]
public function fromURL(Request $request, CreateFromUrlHelper $fromUrlHelper): Response
public function fromURL(Request $request, GenericWebProvider $provider, CreateFromUrlHelper $fromUrlHelper): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@ -244,12 +242,6 @@ class InfoProviderController extends AbstractController
$no_cache = $form->get('no_cache')->getData();
$skip_delegation = $form->get('skip_delegation')->getData();
$submittedPageToken = $request->request->get('submitted_page_token', null);
if ($submittedPageToken !== null && $submittedPageToken !== '') {
$url = $this->browserHtmlStorage->retrieve($submittedPageToken)->url;
}
try {
//It's okay if we use the cached results here, as its just for convenience
$searchResult = $this->infoRetriever->searchByKeyword(
@ -257,7 +249,6 @@ class InfoProviderController extends AbstractController
providers: [$method],
options: [
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
InfoProviderInterface::OPTION_SUBMITTED_PAGE_TOKEN => $submittedPageToken,
]
);
@ -271,7 +262,6 @@ class InfoProviderController extends AbstractController
'providerId' => $searchResult->provider_id,
'no_cache' => $no_cache ? 1 : null,
'skip_delegation' => $skip_delegation ? 1 : null,
'submitted_page_token' => $submittedPageToken ?: null,
]);
}
} catch (ExceptionInterface $e) {
@ -282,7 +272,6 @@ class InfoProviderController extends AbstractController
return $this->render('info_providers/from_url/from_url.html.twig', [
'form' => $form,
'partDetail' => $partDetail,
'recentBrowserPages' => $this->browserHtmlStorage->getRecentPages(),
]);
}

View file

@ -328,12 +328,10 @@ final class PartController extends AbstractController
//Force info providers to not use cache, when retrieving part details for creating a new part, because otherwise we might end up with outdated information
$no_cache = $request->query->getBoolean('no_cache', false);
$skip_delegation = $request->query->getBoolean('skip_delegation', false);
$submitted_page_token = $request->query->getString('submitted_page_token');
$dto = $infoRetriever->getDetails($providerKey, $providerId, [
InfoProviderInterface::OPTION_NO_CACHE => $no_cache,
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
InfoProviderInterface::OPTION_SUBMITTED_PAGE_TOKEN => $submitted_page_token,
]);
$new_part = $infoRetriever->dtoToPart($dto);

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 = min((int) floor((strlen((string) $bytes) - 1) / 3), strlen($sz) - 1);
$factor = (int) floor((strlen((string) $bytes) - 1) / 3);
//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 = min((int) floor(abs($magnitude) / 3), count($prefixes_pos) - 1);
$nearest = (int) floor(abs($magnitude) / 3);
$symbol = $prefixes_pos[$nearest];
} else {
$nearest = min((int) round(abs($magnitude) / 3), count($prefixes_neg) - 1);
$nearest = (int) round(abs($magnitude) / 3);
$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

@ -1,50 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Services\InfoProviderSystem\DTOs;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represents a webpage submitted by the browser extension, held temporarily in the application cache.
*/
final readonly class BrowserSubmittedPage
{
/**
* @var string A unique token for this page, derived from the URL and HTML content. Used to identify the page in the cache without storing the full HTML in the session.
*/
public string $token;
public function __construct(
#[Assert\Url()]
#[Assert\NotBlank]
public string $url,
#[Assert\NotBlank]
#[Assert\Length(max: 5 * 1024 * 1024)] // Limit to 5 MB to prevent abuse
public string $html,
#[Assert\NotBlank]
public string $title,
public \DateTimeImmutable $submittedAt = new \DateTimeImmutable(),
) {
$this->token = hash('xxh3', $url . '|' . $html);
}
}

View file

@ -175,15 +175,15 @@ final class PartInfoRetriever
*/
public function dtoToPart(PartDetailDTO $search_result): Part
{
return $this->dto_to_entity_converter->convertPart($search_result);
return $this->createPart($search_result->provider_key, $search_result->provider_id);
}
/**
* Use the given details to create a part entity
*/
public function createPart(string $provider_key, string $part_id, array $options): Part
public function createPart(string $provider_key, string $part_id): Part
{
$details = $this->getDetails($provider_key, $part_id, $options);
$details = $this->getDetails($provider_key, $part_id);
return $this->dto_to_entity_converter->convertPart($details);
}

View file

@ -27,11 +27,12 @@ namespace App\Services\InfoProviderSystem\Providers;
use App\Exceptions\ProviderIDNotSupportedException;
use App\Helpers\RandomizeUseragentHttpClient;
use App\Services\AI\AIPlatformRegistry;
use App\Services\InfoProviderSystem\SubmittedPageStorage;
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
use App\Services\InfoProviderSystem\DTOJsonSchemaConverter;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Settings\InfoProviderSystem\AIExtractorSettings;
use Brick\Schema\SchemaReader;
use Imagine\Image\Format;
use Jkphl\Micrometa;
use League\HTMLToMarkdown\HtmlConverter;
use Psr\Cache\CacheItemPoolInterface;
@ -61,7 +62,6 @@ final class AIWebProvider implements InfoProviderInterface
private readonly DTOJsonSchemaConverter $jsonSchemaConverter,
private readonly CacheItemPoolInterface $partInfoCache,
private readonly CreateFromUrlHelper $createFromUrlHelper,
private readonly SubmittedPageStorage $browserHtmlStorage,
) {
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
@ -142,17 +142,9 @@ final class AIWebProvider implements InfoProviderInterface
return $cacheItem->get();
}
// Use pre-fetched browser HTML if the option is set and a stored page is available for this URL
$html = null;
if (($token = ($options[self::OPTION_SUBMITTED_PAGE_TOKEN] ?? '')) !== '') {
$html = $this->browserHtmlStorage->retrieve($token)?->html;
}
//Otherwise fetch it ourselves.
if ($html === null) {
// Fetch HTML content
$response = $this->httpClient->request('GET', $url);
$html = $response->getContent();
}
//Convert html to markdown, to provide a cleaner input to the LLM.
$markdown = $this->htmlToMarkdown($html, $url);
@ -184,20 +176,9 @@ final class AIWebProvider implements InfoProviderInterface
*/
private function extractStructuredData(string $html, string $url): string
{
try {
//Only parse microdata, json-ld and rdfa, as they are the most common formats for structured data on product pages. Links and microformat only create clutter for the LLM
$micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA | Micrometa\Ports\Format::RDFA_LITE);
$items = $micrometa($url, $html);
} catch (\RuntimeException $exception) {
//If parsing fails, try again without rdfa, as it seems to cause problems on pages like ebay
try {
$micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA);
$items = $micrometa($url, $html);
} catch (\RuntimeException $exception) {
//If it still fails, return empty structured data
return '{}';
}
}
return json_encode($items->toObject(), JSON_THROW_ON_ERROR);
}

View file

@ -25,7 +25,6 @@ namespace App\Services\InfoProviderSystem\Providers;
use App\Exceptions\ProviderIDNotSupportedException;
use App\Helpers\RandomizeUseragentHttpClient;
use App\Services\InfoProviderSystem\SubmittedPageStorage;
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
@ -58,7 +57,6 @@ class GenericWebProvider implements InfoProviderInterface
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings,
private readonly CreateFromUrlHelper $createFromUrlHelper,
private readonly SubmittedPageStorage $browserHtmlStorage,
)
{
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
@ -296,17 +294,9 @@ class GenericWebProvider implements InfoProviderInterface
}
}
// Use pre-fetched browser HTML if the option is set and a stored page is available for this URL
$content = null;
if (($token = ($options[self::OPTION_SUBMITTED_PAGE_TOKEN] ?? '')) !== '') {
$content = $this->browserHtmlStorage->retrieve($token)?->html;
}
//Otherwise, fetch the page content ourselves
if ($content === null) {
//Try to get the webpage content
$response = $this->httpClient->request('GET', $url);
$content = $response->getContent();
}
$dom = new Crawler($content);

View file

@ -30,7 +30,6 @@ interface InfoProviderInterface
{
public const OPTION_NO_CACHE = 'no_cache'; // if set to true, the provider should not use any cache and retrieve fresh data from the source
public const OPTION_SKIP_DELEGATION = 'skip_delegation'; // if set to true, the provider should not delegate the request to other providers, even if it supports delegation.
public const OPTION_SUBMITTED_PAGE_TOKEN = 'submitted_page_token'; // if set to a non-empty string, the provider should use the browser-submitted page with the given token (and retrieve it from BrowserHtmlSessionStorage)
/**
* Get information about this provider

View file

@ -1,131 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Services\InfoProviderSystem;
use App\Services\InfoProviderSystem\DTOs\BrowserSubmittedPage;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Stores browser-submitted pages for the browser extension feature.
*
* Each page is stored as a {@see BrowserSubmittedPage} DTO in the application cache with a short TTL.
* The session holds only a compact list of recently submitted URLs so that pages can be listed
* without bloating the session with HTML content.
*/
class SubmittedPageStorage
{
private const CACHE_KEY_PREFIX = 'browser_plugin_html_';
private const CACHE_TTL = 1800; // 30 minutes
private const SESSION_KEY = 'browser_plugin_recent_urls';
private const MAX_RECENT = 10;
public function __construct(
private readonly RequestStack $requestStack,
private readonly CacheItemPoolInterface $cache,
) {
}
/**
* Stores a submitted page in the cache and records its URL in the session's recent list.
* @return string The token under which the page was stored, derived from the URL and HTML. This token is used to retrieve the page later. It is the same value as $page->token.
*/
public function store(BrowserSubmittedPage $page): string
{
$item = $this->cache->getItem($this->cacheKey($page));
$item->set($page);
$item->expiresAfter(self::CACHE_TTL);
$this->cache->save($item);
$session = $this->requestStack->getSession();
$tokens = array_values(array_filter(
$session->get(self::SESSION_KEY, []),
static fn(string $u): bool => $u !== $page->token,
));
array_unshift($tokens, $page->token);
$session->set(self::SESSION_KEY, array_slice($tokens, 0, self::MAX_RECENT));
return $page->token;
}
/**
* Retrieves the stored page via its token (which is derived from the URL and HTML). Returns null if not found or expired.
*/
public function retrieve(string $token): ?BrowserSubmittedPage
{
$item = $this->cache->getItem($this->cacheKey($token));
if (!$item->isHit()) {
return null;
}
return $item->get();
}
/**
* Returns the list of recently submitted pages, newest first.
* Pages whose cache entry has expired are silently omitted.
* The list depends on the session and thus is per-browser and per-user.
*
* @return BrowserSubmittedPage[]
*/
public function getRecentPages(): array
{
$tokens = $this->requestStack->getSession()->get(self::SESSION_KEY, []);
$pages = [];
foreach ($tokens as $token) {
$page = $this->retrieve($token);
if ($page !== null) {
$pages[] = $page;
}
}
return $pages;
}
/**
* Removes a page from both the cache and the recent list.
* @param BrowserSubmittedPage|string $page The page or its token to remove.
*/
public function remove(BrowserSubmittedPage|string $page): void
{
$this->cache->deleteItem($this->cacheKey($page));
$token = is_string($page) ? $page : $page->token;
$session = $this->requestStack->getSession();
//Remove the token from the recent list in the session:
$tokens = array_values(array_filter(
$session->get(self::SESSION_KEY, []),
static fn(string $u): bool => $u !== $token
));
$session->set(self::SESSION_KEY, $tokens);
}
private function cacheKey(BrowserSubmittedPage|string $token): string
{
if (!is_string($token)) {
$token = $token->token;
}
return self::CACHE_KEY_PREFIX . $token;
}
}

View file

@ -62,9 +62,6 @@ 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

@ -1,40 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Settings\InfoProviderSystem;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings(name: "browser_plugin", label: new TM("settings.ips.browser_plugin"), description: new TM("settings.ips.browser_plugin.description"))]
#[SettingsIcon("fa-cloud-arrow-up")]
class BrowserPluginSettings
{
#[SettingsParameter(label: new TM("settings.ips.lcsc.enabled"), description: new TM("settings.ips.browser_plugin.enabled.help"),
envVar: "bool:BROWSER_PLUGIN_ENABLED", envVarMode: EnvVarMode::OVERWRITE
)]
public bool $enabled = false;
}

View file

@ -37,9 +37,6 @@ class InfoProviderSettings
#[EmbeddedSettings]
public ?InfoProviderGeneralSettings $general = null;
#[EmbeddedSettings]
public ?BrowserPluginSettings $browserPlugin = null;
#[EmbeddedSettings]
public ?GenericWebProviderSettings $genericWebProvider = null;

View file

@ -33,31 +33,5 @@
</div>
{{ form_row(form.submit) }}
{% if recentBrowserPages is not empty %}
<hr class="{{ offset_label }} mt-4">
<div class="row mb-1">
<label class="col-form-label {{ col_label }}">
{% trans %}browser_plugin.recent_pages.title{% endtrans %}
</label>
<div class="{{ col_input }}">
<p class="text-muted small mb-2">{% trans %}browser_plugin.recent_pages.help{% endtrans %}</p>
<div class="list-group list-group-numbered">
{% for page in recentBrowserPages %}
<button type="submit" name="submitted_page_token" value="{{ page.token }}" formnovalidate
class="list-group-item d-flex justify-content-between align-items-start text-start">
<div class="ms-2 me-auto">
<div class="fw-bold">{{ page.title|u.truncate(160) }}</div>
<small class="text-muted">{{ page.url|u.truncate(160) }}</small>
</div>
<span class="badge text-bg-primary rounded-pill">{{ page.submittedAt|format_time("short") }}</span>
</button>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{{ form_end(form) }}
{% endblock %}

View file

@ -1,222 +0,0 @@
<?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

@ -1,247 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\UserSystem\User;
use App\Settings\InfoProviderSystem\BrowserPluginSettings;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
#[Group("slow")]
#[Group("DB")]
final class BrowserPluginControllerTest extends WebTestCase
{
// --- GET /browser_info ---
public function testGetInfoReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->request('GET', '/en/tools/info_providers/browser_info');
self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
}
public function testGetInfoReturnsForbiddenForUnprivilegedUser(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'noread');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('GET', '/en/tools/info_providers/browser_info');
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}
public function testGetInfoReturns451WhenPluginDisabled(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
// BrowserPluginSettings::$enabled defaults to false
$client->request('GET', '/en/tools/info_providers/browser_info');
self::assertResponseStatusCodeSame(451);
}
public function testGetInfoReturnsJsonWithExpectedKeys(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('GET', '/en/tools/info_providers/browser_info');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertResponseHeaderSame('Content-Type', 'application/json');
$data = json_decode((string) $client->getResponse()->getContent(), true);
$this->assertArrayHasKey('username', $data);
$this->assertArrayHasKey('instance_name', $data);
$this->assertArrayHasKey('url_providers', $data);
$this->assertIsString($data['username']);
$this->assertIsString($data['instance_name']);
$this->assertIsArray($data['url_providers']);
$this->assertNotEmpty($data['username']);
$this->assertNotEmpty($data['instance_name']);
}
public function testGetInfoUrlProvidersHaveIdAndLabel(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('GET', '/en/tools/info_providers/browser_info');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
$data = json_decode((string) $client->getResponse()->getContent(), true);
foreach ($data['url_providers'] as $provider) {
$this->assertArrayHasKey('id', $provider);
$this->assertArrayHasKey('label', $provider);
$this->assertIsString($provider['id']);
$this->assertIsString($provider['label']);
$this->assertNotEmpty($provider['id']);
$this->assertNotEmpty($provider['label']);
}
}
// --- POST /browser_html ---
public function testSubmitHtmlReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['url' => 'https://example.com', 'html' => '<html/>', 'title' => 'Test']));
self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
}
public function testSubmitHtmlReturns451WhenPluginDisabled(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
// BrowserPluginSettings::$enabled defaults to false
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['url' => 'https://example.com', 'html' => '<html/>', 'title' => 'Test']));
self::assertResponseStatusCodeSame(451);
}
public function testSubmitHtmlWithValidDataAndProvider(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'url' => 'https://example.com/product/123',
'html' => '<html><body>Product page</body></html>',
'title' => 'Some Product',
'provider' => 'generic_web',
]));
self::assertResponseStatusCodeSame(Response::HTTP_OK);
$data = json_decode((string) $client->getResponse()->getContent(), true);
$this->assertArrayHasKey('redirect_url', $data);
$this->assertNotNull($data['redirect_url']);
$this->assertStringContainsString('generic_web', (string) $data['redirect_url']);
}
public function testSubmitHtmlWithoutProviderReturnsNullRedirectUrl(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'url' => 'https://example.com/product/123',
'html' => '<html><body>Product page</body></html>',
'title' => 'Some Product',
]));
self::assertResponseStatusCodeSame(Response::HTTP_OK);
$data = json_decode((string) $client->getResponse()->getContent(), true);
$this->assertArrayHasKey('redirect_url', $data);
$this->assertNull($data['redirect_url']);
}
public function testSubmitHtmlWithInvalidJsonReturns400(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], 'this is not valid json {');
self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
}
public function testSubmitHtmlWithMissingUrlReturns422(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['html' => '<html/>', 'title' => 'Test']));
self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
}
public function testSubmitHtmlWithMissingHtmlReturns422(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['url' => 'https://example.com', 'title' => 'Test']));
self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
}
public function testSubmitHtmlWithInvalidUrlReturns422(): void
{
$client = static::createClient();
$client->disableReboot();
$this->loginAsUser($client, 'admin');
static::getContainer()->get(BrowserPluginSettings::class)->enabled = true;
$client->request('POST', '/en/tools/info_providers/browser_html', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['url' => 'not-a-url', 'html' => '<html/>', 'title' => 'Test']));
self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
}
private function loginAsUser(mixed $client, string $username): void
{
$entityManager = static::getContainer()->get('doctrine')->getManager();
$user = $entityManager->getRepository(User::class)->findOneBy(['name' => $username]);
if (!$user) {
$this->markTestSkipped("User '{$username}' not found in fixtures");
}
$client->loginUser($user);
}
}

View file

@ -1,152 +0,0 @@
<?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

@ -1,162 +0,0 @@
<?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

@ -1,103 +0,0 @@
<?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

@ -1,101 +0,0 @@
<?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

@ -1,67 +0,0 @@
<?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

@ -1,110 +0,0 @@
<?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

@ -1,113 +0,0 @@
<?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

@ -1,86 +0,0 @@
<?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

@ -1,103 +0,0 @@
<?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

@ -1,86 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\InfoProviderSystem\DTOs;
use App\Services\InfoProviderSystem\DTOs\BrowserSubmittedPage;
use PHPUnit\Framework\TestCase;
final class BrowserSubmittedPageTest extends TestCase
{
public function testTokenIsNonEmpty(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test');
$this->assertNotEmpty($page->token);
}
public function testTokenIsDeterministic(): void
{
$page1 = new BrowserSubmittedPage('https://example.com', '<html/>', 'Title A');
$page2 = new BrowserSubmittedPage('https://example.com', '<html/>', 'Title B');
// Token is derived from URL + HTML only, title does not affect it
$this->assertSame($page1->token, $page2->token);
}
public function testDifferentUrlProducesDifferentToken(): void
{
$page1 = new BrowserSubmittedPage('https://example.com/1', '<html/>', 'Test');
$page2 = new BrowserSubmittedPage('https://example.com/2', '<html/>', 'Test');
$this->assertNotSame($page1->token, $page2->token);
}
public function testDifferentHtmlProducesDifferentToken(): void
{
$page1 = new BrowserSubmittedPage('https://example.com', '<html>A</html>', 'Test');
$page2 = new BrowserSubmittedPage('https://example.com', '<html>B</html>', 'Test');
$this->assertNotSame($page1->token, $page2->token);
}
public function testTokenMatchesPageTokenProperty(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html>content</html>', 'Test');
$expected = hash('xxh3', 'https://example.com|<html>content</html>');
$this->assertSame($expected, $page->token);
}
public function testDefaultSubmittedAtIsNow(): void
{
$before = new \DateTimeImmutable();
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test');
$after = new \DateTimeImmutable();
$this->assertGreaterThanOrEqual($before->getTimestamp(), $page->submittedAt->getTimestamp());
$this->assertLessThanOrEqual($after->getTimestamp(), $page->submittedAt->getTimestamp());
}
public function testCustomSubmittedAt(): void
{
$dt = new \DateTimeImmutable('2025-01-01 12:00:00');
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test', $dt);
$this->assertSame($dt, $page->submittedAt);
}
}

View file

@ -1,181 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\InfoProviderSystem;
use App\Services\InfoProviderSystem\DTOs\BrowserSubmittedPage;
use App\Services\InfoProviderSystem\SubmittedPageStorage;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
final class SubmittedPageStorageTest extends TestCase
{
private SubmittedPageStorage $storage;
private Session $session;
protected function setUp(): void
{
$this->session = new Session(new MockArraySessionStorage());
$request = new Request();
$request->setSession($this->session);
$requestStack = new RequestStack();
$requestStack->push($request);
$this->storage = new SubmittedPageStorage($requestStack, new ArrayAdapter());
}
public function testStoreReturnsToken(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test');
$token = $this->storage->store($page);
$this->assertSame($page->token, $token);
}
public function testStoreAndRetrieve(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html>content</html>', 'Test Page');
$token = $this->storage->store($page);
$retrieved = $this->storage->retrieve($token);
$this->assertNotNull($retrieved);
$this->assertSame($page->url, $retrieved->url);
$this->assertSame($page->html, $retrieved->html);
$this->assertSame($page->title, $retrieved->title);
$this->assertSame($page->token, $retrieved->token);
}
public function testRetrieveReturnsNullForUnknownToken(): void
{
$this->assertNull($this->storage->retrieve('nonexistent_token_xyz'));
}
public function testStoreReturnsSameTokenForSameUrlAndHtml(): void
{
$page1 = new BrowserSubmittedPage('https://example.com', '<html/>', 'Title One');
$page2 = new BrowserSubmittedPage('https://example.com', '<html/>', 'Title Two');
$this->assertSame($this->storage->store($page1), $this->storage->store($page2));
}
public function testRemoveByTokenDeletesFromCache(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test');
$token = $this->storage->store($page);
$this->storage->remove($token);
$this->assertNull($this->storage->retrieve($token));
}
public function testRemoveByPageObjectDeletesFromCache(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test');
$this->storage->store($page);
$this->storage->remove($page);
$this->assertNull($this->storage->retrieve($page->token));
}
public function testRemoveDeletesFromSession(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test');
$this->storage->store($page);
$this->storage->remove($page);
$this->assertEmpty($this->storage->getRecentPages());
}
public function testGetRecentPagesReturnsStoredPages(): void
{
$page1 = new BrowserSubmittedPage('https://example.com/1', '<html>1</html>', 'Page 1');
$page2 = new BrowserSubmittedPage('https://example.com/2', '<html>2</html>', 'Page 2');
$this->storage->store($page1);
$this->storage->store($page2);
$recent = $this->storage->getRecentPages();
$this->assertCount(2, $recent);
}
public function testGetRecentPagesReturnsNewestFirst(): void
{
$page1 = new BrowserSubmittedPage('https://example.com/1', '<html>1</html>', 'Page 1');
$page2 = new BrowserSubmittedPage('https://example.com/2', '<html>2</html>', 'Page 2');
$this->storage->store($page1);
$this->storage->store($page2);
$recent = $this->storage->getRecentPages();
$this->assertSame($page2->url, $recent[0]->url);
$this->assertSame($page1->url, $recent[1]->url);
}
public function testStoreDeduplicatesSamePageInSession(): void
{
$page = new BrowserSubmittedPage('https://example.com', '<html/>', 'Test');
$this->storage->store($page);
$this->storage->store($page);
$this->assertCount(1, $this->storage->getRecentPages());
}
public function testStoreMovesResubmittedPageToTop(): void
{
$page1 = new BrowserSubmittedPage('https://example.com/1', '<html>1</html>', 'Page 1');
$page2 = new BrowserSubmittedPage('https://example.com/2', '<html>2</html>', 'Page 2');
$this->storage->store($page1);
$this->storage->store($page2);
// Resubmit page1 — it should move back to the top
$this->storage->store($page1);
$recent = $this->storage->getRecentPages();
$this->assertSame($page1->url, $recent[0]->url);
$this->assertSame($page2->url, $recent[1]->url);
}
public function testGetRecentPagesSilentlyOmitsExpiredEntries(): void
{
// Put a token in the session that has no corresponding cache entry (simulates expiry)
$this->session->set('browser_plugin_recent_urls', ['expired_token_xyz']);
$this->assertEmpty($this->storage->getRecentPages());
}
public function testSessionCappedAtTenEntries(): void
{
for ($i = 0; $i < 12; $i++) {
$page = new BrowserSubmittedPage("https://example.com/{$i}", "<html>{$i}</html>", "Page {$i}");
$this->storage->store($page);
}
$this->assertCount(10, $this->storage->getRecentPages());
}
}

View file

@ -87,32 +87,4 @@ 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

@ -1,133 +0,0 @@
<?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

@ -1,79 +0,0 @@
<?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

@ -1,92 +0,0 @@
<?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

@ -1,85 +0,0 @@
<?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,7 +4,6 @@ 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;
@ -168,223 +167,6 @@ 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,36 +59,4 @@ 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,12 +24,10 @@ 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
@ -89,181 +87,4 @@ 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,132 +240,6 @@ 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

@ -1,105 +0,0 @@
<?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

@ -1,95 +0,0 @@
<?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

@ -1,91 +0,0 @@
<?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,33 +154,6 @@ 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

@ -1,103 +0,0 @@
<?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

@ -1,81 +0,0 @@
<?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,54 +24,52 @@ 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

@ -1,123 +0,0 @@
<?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

@ -1,73 +0,0 @@
<?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();
}
}

View file

@ -13607,35 +13607,5 @@ Buerklin-API Authentication server:
<target>Host URL</target>
</segment>
</unit>
<unit id="kuDv.So" name="browser_plugin.recent_pages.title">
<segment state="translated">
<source>browser_plugin.recent_pages.title</source>
<target>Recent browser submissions</target>
</segment>
</unit>
<unit id="AjNj8wk" name="browser_plugin.recent_pages.help">
<segment state="translated">
<source>browser_plugin.recent_pages.help</source>
<target>Pages recently submitted from your browser extension. Click to create a part using the captured HTML.</target>
</segment>
</unit>
<unit id="lVUU9s7" name="settings.ips.browser_plugin">
<segment>
<source>settings.ips.browser_plugin</source>
<target>Browser plugin</target>
</segment>
</unit>
<unit id="IrJs3fI" name="settings.ips.browser_plugin.description">
<segment>
<source>settings.ips.browser_plugin.description</source>
<target>The browser plugin allows to submit pages to Part-DB directly from a browser to create new parts. HTML content is submitted, so that extraction even works on DDOS protected pages, or pages requiring javascript for correct rendering. The Generic Web or AI Web extractor needs to be enabled to be useful.</target>
</segment>
</unit>
<unit id="_8UrMCB" name="settings.ips.browser_plugin.enabled.help">
<segment>
<source>settings.ips.browser_plugin.enabled.help</source>
<target>When enabled users with the info provider permission can submit pages to Part-DB and retrieve them later.</target>
</segment>
</unit>
</file>
</xliff>

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.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==
"@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==
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.5"
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.5.tgz#c48b7ed94582c8b685e21b8b42de8633ec289268"
integrity sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==
version "7.29.3"
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.3.tgz#2bbd5b0162e6a762adfe356f4aecdef837a3d574"
integrity sha512-ySZypNLAIH1ClygLDQzVMoGQRViATnkHkYYV6TcNDz+8+jwZCdsguGvsb3EY5d9wyWyhmF1iSuFM0Yh5XPnqSA==
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.4"
"@babel/plugin-transform-modules-systemjs" "^7.29.0"
"@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.4.0":
version "30.4.0"
resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.4.0.tgz#fcb519eeacc25caa3768f787595a27afa15302ae"
integrity sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==
"@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==
dependencies:
"@types/node" "*"
jest-regex-util "30.4.0"
jest-regex-util "30.0.1"
"@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==
"@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==
dependencies:
"@sinclair/typebox" "^0.34.0"
"@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==
"@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==
dependencies:
"@jest/pattern" "30.4.0"
"@jest/schemas" "30.4.1"
"@jest/pattern" "30.0.1"
"@jest/schemas" "30.0.5"
"@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.9"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@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.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.2.tgz#8c491201373690e4ef2a2ffed0dfb510a5830b92"
integrity sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==
version "25.6.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca"
integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==
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.1"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz#0e8f34854df7966b09304a18e808b23997bb9fc1"
integrity sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==
version "1.3.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
"@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.29"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz#47bdc13027af28d341f367a4f35a07ce872e27b4"
integrity sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==
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==
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.30001792"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5"
integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==
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==
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.353"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz#01e8a8e25a0bf13e631106045f177d0568ca91c2"
integrity sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==
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==
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.3"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz#fa7fed23679e9169dfb705b8e201924421c4414a"
integrity sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==
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==
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.2"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec"
integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==
version "3.1.1"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.1.tgz#dd085fec2494a2a33bac6e61277374669e1dd774"
integrity sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==
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.3:
hasown@^2.0.2:
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.2"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082"
integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==
version "2.16.1"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
dependencies:
hasown "^2.0.3"
hasown "^2.0.2"
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.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-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-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==
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==
dependencies:
"@jest/types" "30.4.1"
"@jest/types" "30.3.0"
"@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.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==
version "30.3.0"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.3.0.tgz#ae4dc1f1d93d0cba1415624fcedaec40ea764f14"
integrity sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==
dependencies:
"@types/node" "*"
"@ungap/structured-clone" "^1.3.0"
jest-util "30.4.1"
jest-util "30.3.0"
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.8"
resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.3.8.tgz#cebff884636fddda02af04599530355aa855131f"
integrity sha512-ywj3MESfqOW7sOjXZiBKaWk7XLncZ9caMflM3WSbc0Do8Wpwn9DBV8ceKZqkz1M/avl8i+ccS2f8THZRyFaCGQ==
version "0.3.7"
resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.3.7.tgz#7db4f5d83306d344cda20afdd59cd09cf4acdae1"
integrity sha512-SwTFcaH3kCJBlPFWi/YB34zRg6lpCxq90tkZ9GxfSi9/v4Tk96cv4IvOstA+CC40rdW1OzQIuNhD2DLD1RDVgA==
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.8.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df"
integrity sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
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.6.0"
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3"
integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==
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==
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.47.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.47.1.tgz#99b298e51bc41214304847de1429ec92fd1f7648"
integrity sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==
version "5.46.2"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.2.tgz#b9529672d5b0024c7959571c83b82f65077b2a4f"
integrity sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==
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.1"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.6.1.tgz#54be5c4431d5d59c8c4897e6e051963bac11f44a"
integrity sha512-d/1kngVOQTGcI/2pVDfDLYjtjUgSSd3fSgkYUpi0y+yRtQQu2kzljj3aUdqMfqc45cjPvDEpfDt/hSX4awDFTg==
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==
dependencies:
"@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.1.2"