mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-06-06 02:31:45 +00:00
Compare commits
36 commits
b131a6dbe4
...
321aac5151
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321aac5151 | ||
|
|
e8af0e9b4f | ||
|
|
82cd5875fc | ||
|
|
2e223f4ee4 | ||
|
|
0da5befd7b | ||
|
|
87874230ef | ||
|
|
cf5a9e5667 | ||
|
|
f15e0ced2a | ||
|
|
79c36494ea | ||
|
|
ad0c60f766 | ||
|
|
527c42c227 | ||
|
|
506d5f8173 | ||
|
|
846ecdf02e | ||
|
|
a9fa92c98e | ||
|
|
d237446334 | ||
|
|
5ecf51e7cf | ||
|
|
f214cc1d71 | ||
|
|
a442c0728a | ||
|
|
01886e8ce5 | ||
|
|
b11f4a4f9c | ||
|
|
c8b2db8cd3 | ||
|
|
dd53a9285d | ||
|
|
d0c48c1dc9 | ||
|
|
91a6a26746 | ||
|
|
e33c13ecfa | ||
|
|
6a3be77ec0 | ||
|
|
e10bf89d6d | ||
|
|
23431d3d31 | ||
|
|
3431320d03 | ||
|
|
2ae433a74d | ||
|
|
a6ef9a58ec | ||
|
|
112e962239 | ||
|
|
47ab18175f | ||
|
|
7d27bff062 | ||
|
|
f3f93a8205 | ||
|
|
65a6f46369 |
94 changed files with 8200 additions and 3515 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
[](https://scrutinizer-ci.com/g/Part-DB/Part-DB-symfony/?branch=master)
|
|
||||||

|

|
||||||

|

|
||||||
[](https://codecov.io/gh/Part-DB/Part-DB-server)
|
[](https://codecov.io/gh/Part-DB/Part-DB-server)
|
||||||
|
|
@ -62,7 +61,8 @@ for the first time.
|
||||||
* Automatic thumbnail generation for pictures
|
* Automatic thumbnail generation for pictures
|
||||||
* Use cloud providers (like Octopart, Digikey, Farnell, LCSC or TME) to automatically get part information, datasheets, and
|
* Use cloud providers (like Octopart, Digikey, Farnell, LCSC or TME) to automatically get part information, datasheets, and
|
||||||
prices for parts
|
prices for parts
|
||||||
* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction
|
* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction.
|
||||||
|
A browser plugin allows to quickly submit parts from any website to your Part-DB instance, and even allows to circumvent anti-bot measures on shop websites.
|
||||||
* API to access Part-DB from other applications/scripts
|
* API to access Part-DB from other applications/scripts
|
||||||
* [Integration with KiCad](https://docs.part-db.de/usage/eda_integration.html): Use Part-DB as the central datasource for your
|
* [Integration with KiCad](https://docs.part-db.de/usage/eda_integration.html): Use Part-DB as the central datasource for your
|
||||||
KiCad and see available parts from Part-DB directly inside KiCad.
|
KiCad and see available parts from Part-DB directly inside KiCad.
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
2.11.1
|
2.12.0
|
||||||
|
|
|
||||||
274
assets/controllers/common/dirty_form_controller.js
Normal file
274
assets/controllers/common/dirty_form_controller.js
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2025 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Controller} from "@hotwired/stimulus";
|
||||||
|
import {visit} from "@hotwired/turbo";
|
||||||
|
import * as bootbox from "bootbox";
|
||||||
|
import "../../css/components/bootbox_extensions.css";
|
||||||
|
import "../../css/components/dirty_form.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to a <form> element (or a wrapper containing a <form>) to prevent accidental navigation
|
||||||
|
* away when the form has unsaved changes.
|
||||||
|
*
|
||||||
|
* Dirty detection is event-driven: `change` and `input` events bubble up to the form and trigger
|
||||||
|
* a check of whether any element's current value differs from the DOM default recorded in the HTML
|
||||||
|
* (`defaultValue` / `defaultChecked` / `option.defaultSelected`). Using both events covers both
|
||||||
|
* native widgets (which fire `change`) and rich-text editors like CKEditor (which fire `input`
|
||||||
|
* when they sync their underlying textarea).
|
||||||
|
*
|
||||||
|
* Validation failures (server returns 200 with `.is-invalid` fields) are always treated as dirty:
|
||||||
|
* the submitted data was never saved, so navigating away would lose it. This removes the need for
|
||||||
|
* any snapshot mechanism — the `.is-invalid` classes in the re-rendered HTML are the signal.
|
||||||
|
*
|
||||||
|
* Intercepts three navigation paths:
|
||||||
|
* 1. Any <a href> link click (capture phase)
|
||||||
|
* 2. window beforeunload
|
||||||
|
* 3. turbo:before-visit
|
||||||
|
*
|
||||||
|
* Values:
|
||||||
|
* - confirmTitle (String) – dialog title
|
||||||
|
* - confirmMessage (String) – dialog body text
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
confirmTitle: {type: String, default: 'Unsaved Changes'},
|
||||||
|
confirmMessage: {type: String, default: 'You have unsaved changes. Are you sure you want to leave this page?'},
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this._form = (this.element.tagName === 'FORM') ? this.element : this.element.querySelector('form');
|
||||||
|
this._isDirty = false;
|
||||||
|
this._submitting = false;
|
||||||
|
this._navigating = false;
|
||||||
|
|
||||||
|
this._changeHandler = this._handleChange.bind(this);
|
||||||
|
this._linkClickHandler = this._handleLinkClick.bind(this);
|
||||||
|
this._beforeUnloadHandler = this._handleBeforeUnload.bind(this);
|
||||||
|
this._turboBeforeVisitHandler = this._handleTurboBeforeVisit.bind(this);
|
||||||
|
this._turboSubmitEndHandler = this._handleTurboSubmitEnd.bind(this);
|
||||||
|
|
||||||
|
if (this._form) {
|
||||||
|
this._form.addEventListener('change', this._changeHandler);
|
||||||
|
// CKEditor (and other rich-text widgets) dispatch `input` rather than `change`
|
||||||
|
// when their underlying textarea value is updated.
|
||||||
|
this._form.addEventListener('input', this._changeHandler);
|
||||||
|
}
|
||||||
|
document.addEventListener('click', this._linkClickHandler, true);
|
||||||
|
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
||||||
|
document.addEventListener('turbo:before-visit', this._turboBeforeVisitHandler);
|
||||||
|
document.addEventListener('turbo:submit-end', this._turboSubmitEndHandler);
|
||||||
|
|
||||||
|
const modal = this.element.closest('.modal');
|
||||||
|
if (modal) {
|
||||||
|
this._modal = modal;
|
||||||
|
this._modalHideHandler = this._handleModalHide.bind(this);
|
||||||
|
modal.addEventListener('hide.bs.modal', this._modalHideHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this._form) {
|
||||||
|
this._form.removeEventListener('change', this._changeHandler);
|
||||||
|
this._form.removeEventListener('input', this._changeHandler);
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', this._linkClickHandler, true);
|
||||||
|
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
||||||
|
document.removeEventListener('turbo:before-visit', this._turboBeforeVisitHandler);
|
||||||
|
document.removeEventListener('turbo:submit-end', this._turboSubmitEndHandler);
|
||||||
|
|
||||||
|
if (this._modal && this._modalHideHandler) {
|
||||||
|
this._modal.removeEventListener('hide.bs.modal', this._modalHideHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** data-action="submit->common--dirty-form#submit" — suppresses the guard while saving. */
|
||||||
|
submit() {
|
||||||
|
this._submitting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* data-action="reset->common--dirty-form#resetDirtyState" — marks the form as clean after
|
||||||
|
* a programmatic reset. Native change events are not fired by form.reset(), so we set the
|
||||||
|
* flag directly. Turbo also calls form.reset() internally before the post-submit redirect;
|
||||||
|
* the _submitting guard prevents that from incorrectly clearing the flag.
|
||||||
|
*/
|
||||||
|
resetDirtyState() {
|
||||||
|
if (this._submitting) return;
|
||||||
|
|
||||||
|
// Wait for a frame to allow the form's DOM state to update after the reset() call, then refresh markers and update the dirty flag.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this._isDirty = false;
|
||||||
|
this._clearDirtyMarkers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleChange(event) {
|
||||||
|
const target = event?.target;
|
||||||
|
if (target?.name) {
|
||||||
|
this._updateDirtyMarker(target);
|
||||||
|
} else {
|
||||||
|
this._refreshDirtyMarkers();
|
||||||
|
}
|
||||||
|
this._isDirty = this._form?.querySelector('[data-dirty]') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every named form element and update its `data-dirty` attribute.
|
||||||
|
* Un-named elements (e.g. the visible TristateCheckbox whose name was removed) are
|
||||||
|
* skipped — they are not submitted and are not the source of truth for form data.
|
||||||
|
*/
|
||||||
|
_refreshDirtyMarkers() {
|
||||||
|
if (!this._form) return;
|
||||||
|
for (const el of this._form.elements) {
|
||||||
|
if (!el.name) continue;
|
||||||
|
this._updateDirtyMarker(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or clear `data-dirty` on a single named form element.
|
||||||
|
* Hidden inputs are not visually rendered, so special handling applies:
|
||||||
|
* - TristateCheckbox: the hidden backing input is preceded by a nameless visual checkbox —
|
||||||
|
* mark that instead.
|
||||||
|
* - Other hidden inputs (e.g. CSRF tokens): ignored.
|
||||||
|
* TomSelect hides the <select> before .ts-wrapper (sibling); CSS targets .ts-control via the
|
||||||
|
* adjacent-sibling combinator on the select's data-dirty attribute.
|
||||||
|
*/
|
||||||
|
_updateDirtyMarker(el) {
|
||||||
|
if (el.type === 'hidden') {
|
||||||
|
const visual = el.previousElementSibling;
|
||||||
|
if (visual instanceof HTMLInputElement && !visual.name) {
|
||||||
|
visual.toggleAttribute('data-dirty', el.value !== el.defaultValue);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirty = this._isElementDirty(el);
|
||||||
|
el.toggleAttribute('data-dirty', dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearDirtyMarkers() {
|
||||||
|
this._form?.querySelectorAll('[data-dirty]').forEach(el => el.removeAttribute('data-dirty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
_isElementDirty(el) {
|
||||||
|
//Disabled elements are not editable, so ignore them even if their value differs from the default.
|
||||||
|
if (el.disabled) return false;
|
||||||
|
|
||||||
|
if (el.type === 'file') return false;
|
||||||
|
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||||
|
return el.checked !== el.defaultChecked;
|
||||||
|
}
|
||||||
|
if (el.tagName === 'SELECT') {
|
||||||
|
// TomSelect sets data-default-value to the value at init time.
|
||||||
|
// The native option.defaultSelected approach is unreliable when no option
|
||||||
|
// carries the `selected` attribute — the browser auto-selects option[0]
|
||||||
|
// (selected=true) while defaultSelected stays false, causing a false positive.
|
||||||
|
if (el.dataset.defaultValue !== undefined) {
|
||||||
|
return el.value !== el.dataset.defaultValue;
|
||||||
|
}
|
||||||
|
for (const option of el.options) {
|
||||||
|
if (option.selected !== option.defaultSelected) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultValue = el.defaultValue;
|
||||||
|
|
||||||
|
//If an element has an data-default-value, use that for dirty checking instead of the DOM default Value. Set for example by the ckeditor-controller
|
||||||
|
if (el.dataset.defaultValue !== undefined) {
|
||||||
|
defaultValue = el.dataset.defaultValue;
|
||||||
|
}
|
||||||
|
return el.value !== defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isFormDirty() {
|
||||||
|
if (this._submitting) return false;
|
||||||
|
// A form with validation errors was submitted but never saved — always treat as dirty.
|
||||||
|
if (this._form?.querySelector('.is-invalid')) return true;
|
||||||
|
return this._isDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
_confirmNavigation(onConfirm) {
|
||||||
|
bootbox.confirm({
|
||||||
|
title: this.confirmTitleValue,
|
||||||
|
message: this.confirmMessageValue,
|
||||||
|
callback: (result) => { if (result) onConfirm(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleLinkClick(event) {
|
||||||
|
if (this._navigating) return;
|
||||||
|
|
||||||
|
const link = event.target.closest('a[href]');
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (!href || href.startsWith('#')) return;
|
||||||
|
if (link.target === '_blank' || link.target === '_top' || link.target === '_parent') return;
|
||||||
|
if (link.hasAttribute('data-dirty-form-ignore')) return;
|
||||||
|
|
||||||
|
if (!this._isFormDirty()) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this._confirmNavigation(() => { this._navigating = true; link.click(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleBeforeUnload(event) {
|
||||||
|
if (this._navigating || !this._isFormDirty()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleTurboBeforeVisit(event) {
|
||||||
|
if (this._navigating || !this._isFormDirty()) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const url = event.detail.url;
|
||||||
|
const frame = event.detail.frame;
|
||||||
|
this._confirmNavigation(() => {
|
||||||
|
this._navigating = true;
|
||||||
|
if (frame) { window.Turbo.visit(url, { frame }); } else { visit(url); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleTurboSubmitEnd(event) {
|
||||||
|
const submittedForm = event.detail?.formSubmission?.formElement;
|
||||||
|
if (submittedForm !== this._form) return;
|
||||||
|
|
||||||
|
// For a successful save (redirect), the controller will disconnect with the Turbo
|
||||||
|
// navigation; reset is only needed for validation errors where the form stays in the DOM.
|
||||||
|
const savedSuccessfully = event.detail.success && event.detail.fetchResponse?.redirected;
|
||||||
|
if (!savedSuccessfully) {
|
||||||
|
this._submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleModalHide(event) {
|
||||||
|
if (this._navigating || !this._isFormDirty()) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this._confirmNavigation(() => {
|
||||||
|
this._navigating = true;
|
||||||
|
window.bootstrap?.Modal?.getInstance(this._modal)?.hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
||||||
|
|
||||||
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
@ -82,7 +84,8 @@ export default class extends Controller {
|
||||||
'autoselect_typed': {},
|
'autoselect_typed': {},
|
||||||
'click_to_edit': {},
|
'click_to_edit': {},
|
||||||
'clear_button': {},
|
'clear_button': {},
|
||||||
"restore_on_backspace": {}
|
'restore_on_backspace': {},
|
||||||
|
'form_reset_handler': {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
||||||
|
|
||||||
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
@ -64,7 +66,8 @@ export default class extends Controller {
|
||||||
'autoselect_typed': {},
|
'autoselect_typed': {},
|
||||||
'click_to_edit': {},
|
'click_to_edit': {},
|
||||||
'clear_button': {},
|
'clear_button': {},
|
||||||
"restore_on_backspace": {}
|
'restore_on_backspace': {},
|
||||||
|
'form_reset_handler': {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ export default class extends Controller {
|
||||||
config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
|
config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Apply the default value of the source element as data attribute, so that dirty-form-controller can detect changes
|
||||||
|
this.element.dataset.defaultValue = this.element.defaultValue;
|
||||||
|
|
||||||
const watchdog = new EditorWatchdog();
|
const watchdog = new EditorWatchdog();
|
||||||
watchdog.setCreator((elementOrData, editorConfig) => {
|
watchdog.setCreator((elementOrData, editorConfig) => {
|
||||||
return EDITOR_TYPE.create(elementOrData, editorConfig)
|
return EDITOR_TYPE.create(elementOrData, editorConfig)
|
||||||
|
|
@ -111,10 +114,21 @@ export default class extends Controller {
|
||||||
editor.updateSourceElement();
|
editor.updateSourceElement();
|
||||||
|
|
||||||
// Dispatch the input event for further treatment
|
// Dispatch the input event for further treatment
|
||||||
const event = new Event("input");
|
this.element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
this.element.dispatchEvent(event);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Set an reset listener to update the editor if the source element is reset (e.g. by a reset button)
|
||||||
|
if (this.element.form && this.element.name) {
|
||||||
|
this.element.form.addEventListener("reset", () => {
|
||||||
|
if (editor.isReadOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.element.dataset.defaultValue !== undefined) {
|
||||||
|
editor.setData(this.element.dataset.defaultValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
//This return is important! Otherwise we get mysterious errors in the console
|
//This return is important! Otherwise we get mysterious errors in the console
|
||||||
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
|
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
|
||||||
return editor;
|
return editor;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
|
||||||
import '../../css/components/tom-select_extensions.css';
|
import '../../css/components/tom-select_extensions.css';
|
||||||
import TomSelect from "tom-select";
|
import TomSelect from "tom-select";
|
||||||
import {marked} from "marked";
|
import {marked} from "marked";
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
@ -18,7 +21,7 @@ export default class extends Controller {
|
||||||
|
|
||||||
let settings = {
|
let settings = {
|
||||||
allowEmptyOption: true,
|
allowEmptyOption: true,
|
||||||
plugins: ['dropdown_input', this.element.required ? null : 'clear_button'],
|
plugins: ['dropdown_input', this.element.required ? null : 'clear_button', 'form_reset_handler'],
|
||||||
searchField: ["name", "description", "category", "footprint"],
|
searchField: ["name", "description", "category", "footprint"],
|
||||||
valueField: "id",
|
valueField: "id",
|
||||||
labelField: "name",
|
labelField: "name",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ import {Controller} from "@hotwired/stimulus";
|
||||||
import "tom-select/dist/css/tom-select.bootstrap5.css";
|
import "tom-select/dist/css/tom-select.bootstrap5.css";
|
||||||
import '../../css/components/tom-select_extensions.css';
|
import '../../css/components/tom-select_extensions.css';
|
||||||
import TomSelect from "tom-select";
|
import TomSelect from "tom-select";
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
|
||||||
|
|
@ -44,7 +47,7 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = {
|
let settings = {
|
||||||
plugins: ["clear_button"],
|
plugins: ["clear_button", "form_reset_handler"],
|
||||||
allowEmptyOption: true,
|
allowEmptyOption: true,
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
maxOptions: null,
|
maxOptions: null,
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
||||||
|
|
||||||
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the frontend controller for StaticFileAutocompleteType form element.
|
* This is the frontend controller for StaticFileAutocompleteType form element.
|
||||||
|
|
@ -64,7 +66,8 @@ export default class extends Controller {
|
||||||
'autoselect_typed': {},
|
'autoselect_typed': {},
|
||||||
'click_to_edit': {},
|
'click_to_edit': {},
|
||||||
'clear_button': {},
|
'clear_button': {},
|
||||||
'restore_on_backspace': {}
|
'restore_on_backspace': {},
|
||||||
|
'form_reset_handler': {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ import TomSelect from "tom-select";
|
||||||
import {Controller} from "@hotwired/stimulus";
|
import {Controller} from "@hotwired/stimulus";
|
||||||
|
|
||||||
import {trans} from '../../translator.js'
|
import {trans} from '../../translator.js'
|
||||||
|
|
||||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
@ -96,6 +98,7 @@ export default class extends Controller {
|
||||||
|
|
||||||
plugins: {
|
plugins: {
|
||||||
"autoselect_typed": {},
|
"autoselect_typed": {},
|
||||||
|
"form_reset_handler": {},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -105,6 +108,7 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._tomSelect = new TomSelect(this.element, settings);
|
this._tomSelect = new TomSelect(this.element, settings);
|
||||||
|
|
||||||
//Do not do a sync here as this breaks the initial rendering of the empty option
|
//Do not do a sync here as this breaks the initial rendering of the empty option
|
||||||
//this._tomSelect.sync();
|
//this._tomSelect.sync();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
||||||
|
|
||||||
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
@ -43,6 +45,7 @@ export default class extends Controller {
|
||||||
remove_button:{},
|
remove_button:{},
|
||||||
'autoselect_typed': {},
|
'autoselect_typed': {},
|
||||||
'click_to_edit': {},
|
'click_to_edit': {},
|
||||||
|
'form_reset_handler': {},
|
||||||
},
|
},
|
||||||
persistent: false,
|
persistent: false,
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,18 @@ export default class extends Controller {
|
||||||
onNodeSelected: (event) => {
|
onNodeSelected: (event) => {
|
||||||
const node = event.detail.node;
|
const node = event.detail.node;
|
||||||
if (node.href) {
|
if (node.href) {
|
||||||
window.Turbo.visit(node.href, {action: "advance", frame: this._frame});
|
const url = node.href;
|
||||||
|
// Turbo.visit with a frame target bypasses turbo:before-visit, so dispatch it
|
||||||
|
// manually so that dirty-form guards can intercept it.
|
||||||
|
const beforeVisitEvent = new CustomEvent('turbo:before-visit', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
detail: { url, frame: this._frame },
|
||||||
|
});
|
||||||
|
document.dispatchEvent(beforeVisitEvent);
|
||||||
|
if (!beforeVisitEvent.defaultPrevented) {
|
||||||
|
window.Turbo.visit(url, {action: "advance", frame: this._frame});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, [BS5Theme, BS53Theme, FAIconTheme]);
|
}, [BS5Theme, BS53Theme, FAIconTheme]);
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ import "katex/dist/katex.css";
|
||||||
|
|
||||||
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||||
|
|
||||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
/* stimulusFetch: 'lazy' */
|
||||||
export default class extends Controller
|
export default class extends Controller
|
||||||
|
|
@ -63,7 +65,8 @@ export default class extends Controller
|
||||||
'autoselect_typed': {},
|
'autoselect_typed': {},
|
||||||
'click_to_edit': {},
|
'click_to_edit': {},
|
||||||
'clear_button': {},
|
'clear_button': {},
|
||||||
'restore_on_backspace': {}
|
'restore_on_backspace': {},
|
||||||
|
'form_reset_handler': {}
|
||||||
},
|
},
|
||||||
persistent: false,
|
persistent: false,
|
||||||
maxItems: 1,
|
maxItems: 1,
|
||||||
|
|
|
||||||
61
assets/css/components/dirty_form.css
Normal file
61
assets/css/components/dirty_form.css
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2025 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Standard Bootstrap form controls */
|
||||||
|
.form-control[data-dirty],
|
||||||
|
.form-select[data-dirty],
|
||||||
|
input[data-dirty] + .form-control,
|
||||||
|
select[data-dirty] + .form-select
|
||||||
|
{
|
||||||
|
border-color: var(--bs-info);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control[data-dirty]:focus,
|
||||||
|
.form-select[data-dirty]:focus,
|
||||||
|
input[data-dirty] + .form-control:focus,
|
||||||
|
select[data-dirty] + .form-select:focus
|
||||||
|
{
|
||||||
|
border-color: var(--bs-info);
|
||||||
|
border-width: 2px;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes and radios */
|
||||||
|
.form-check-input[data-dirty] {
|
||||||
|
border-color: var(--bs-info);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input[data-dirty]:checked {
|
||||||
|
background-color: var(--bs-info);
|
||||||
|
border-color: var(--bs-info);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CKEditor: the editable sits after the hidden source textarea in the DOM */
|
||||||
|
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable:not(.ck-focused) {
|
||||||
|
border-color: var(--bs-info) !important;
|
||||||
|
border-width: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable.ck-focused {
|
||||||
|
border-width: 2px !important;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25) !important;
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,8 @@ export default class TristateCheckbox {
|
||||||
|
|
||||||
//Do a refresh to set the correct styling of the checkbox
|
//Do a refresh to set the correct styling of the checkbox
|
||||||
this._refresh();
|
this._refresh();
|
||||||
|
// Anchor the hidden input's default so dirty-form comparisons have a clean baseline.
|
||||||
|
this._hiddenInput.defaultValue = this._hiddenInput.value;
|
||||||
|
|
||||||
this._element.addEventListener('click', this.click.bind(this));
|
this._element.addEventListener('click', this.click.bind(this));
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +204,9 @@ export default class TristateCheckbox {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._refresh();
|
this._refresh();
|
||||||
|
// Notify change listeners (e.g. dirty-form controller) since programmatic
|
||||||
|
// value changes don't fire native change events.
|
||||||
|
this._hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ class TristateHelper {
|
||||||
|
|
||||||
document.addEventListener("turbo:load", listener);
|
document.addEventListener("turbo:load", listener);
|
||||||
document.addEventListener("turbo:render", listener);
|
document.addEventListener("turbo:render", listener);
|
||||||
|
document.addEventListener("turbo:frame-load", listener);
|
||||||
document.addEventListener("collection:elementAdded", listener);
|
document.addEventListener("collection:elementAdded", listener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
assets/tomselect/form_reset_handler/form_reset_handler.js
Normal file
46
assets/tomselect/form_reset_handler/form_reset_handler.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TomSelect plugin for dirty-check integration and form reset support.
|
||||||
|
*
|
||||||
|
* Sets data-default-value on the underlying <select> so the dirty-check
|
||||||
|
* controller can detect changes, and restores that value when the form is reset.
|
||||||
|
*/
|
||||||
|
export default function form_reset_handler() {
|
||||||
|
const self = this;
|
||||||
|
const input = this.input;
|
||||||
|
|
||||||
|
// Multiple selects not yet supported
|
||||||
|
if (input.multiple) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always capture the initial value, even empty string.
|
||||||
|
// Empty string is falsy, so the old `|| null` guard would silently skip it,
|
||||||
|
// leaving data-default-value unset and breaking the dirty check for blank defaults.
|
||||||
|
input.dataset.defaultValue = input.value;
|
||||||
|
|
||||||
|
if (input.form) {
|
||||||
|
input.form.addEventListener('reset', () => {
|
||||||
|
input.value = input.dataset.defaultValue ?? '';
|
||||||
|
self.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"api-platform/json-api": "^4.0.0",
|
"api-platform/json-api": "^4.0.0",
|
||||||
"api-platform/symfony": "^4.0.0",
|
"api-platform/symfony": "^4.0.0",
|
||||||
"beberlei/doctrineextensions": "^1.2",
|
"beberlei/doctrineextensions": "^1.2",
|
||||||
"brick/math": "^0.14.8",
|
"brick/math": "^0.17.0",
|
||||||
"brick/schema": "^0.2.0",
|
"brick/schema": "^0.2.0",
|
||||||
"composer/ca-bundle": "^1.5",
|
"composer/ca-bundle": "^1.5",
|
||||||
"composer/package-versions-deprecated": "^1.11.99.5",
|
"composer/package-versions-deprecated": "^1.11.99.5",
|
||||||
|
|
@ -57,9 +57,9 @@
|
||||||
"scheb/2fa-trusted-device": "^v7.11.0",
|
"scheb/2fa-trusted-device": "^v7.11.0",
|
||||||
"shivas/versioning-bundle": "^4.0",
|
"shivas/versioning-bundle": "^4.0",
|
||||||
"spatie/db-dumper": "^3.3.1",
|
"spatie/db-dumper": "^3.3.1",
|
||||||
"symfony/ai-bundle": "^0.8.0",
|
"symfony/ai-bundle": "^0.9.0",
|
||||||
"symfony/ai-lm-studio-platform": "^0.8.0",
|
"symfony/ai-lm-studio-platform": "^0.9.0",
|
||||||
"symfony/ai-open-router-platform": "^0.8.0",
|
"symfony/ai-open-router-platform": "^0.9.0",
|
||||||
"symfony/apache-pack": "^1.0",
|
"symfony/apache-pack": "^1.0",
|
||||||
"symfony/asset": "7.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/console": "7.4.*",
|
"symfony/console": "7.4.*",
|
||||||
|
|
|
||||||
1283
composer.lock
generated
1283
composer.lock
generated
File diff suppressed because it is too large
Load diff
2891
config/reference.php
2891
config/reference.php
File diff suppressed because it is too large
Load diff
|
|
@ -75,6 +75,15 @@ the parts you want to update. In the bulk actions dropdown select "Bulk info pro
|
||||||
You will be redirected to a page, where you can select how part fields should be mapped to info provider fields, and the
|
You will be redirected to a page, where you can select how part fields should be mapped to info provider fields, and the
|
||||||
results will be shown.
|
results will be shown.
|
||||||
|
|
||||||
|
## Browser plugin
|
||||||
|
There is a browser plugin available for [Chrome](https://chromewebstore.google.com/detail/part-db-page-submitter/bckkfkpidiiibmjdhjakleoagjmepioi) and [Firefox](https://addons.mozilla.org/de/firefox/addon/part-db-page-submitter/)
|
||||||
|
that allows to submit a website from your browser with one click to Part-DB, which then utilizes the Generic Web URL or the AI Web Provider to extract the part information from the page and pre-fill the part creation form.
|
||||||
|
The advantage is that it also works for pages behind logins, CAPTCHAs, or bot-blocking sites, as the plugin sends the already loaded page HTML to Part-DB.
|
||||||
|
The plugin is open source and available on [GitHub](https://github.com/Part-DB/browser-plugin).
|
||||||
|
|
||||||
|
To use it install it in your browser, enable one or more of the web page providers in Part-DB and allow the plugin support
|
||||||
|
in Part-DB settings. After that you can submit any product page to Part-DB with one click and the part creation form will be pre-filled with the information from the page.
|
||||||
|
|
||||||
## Data providers
|
## Data providers
|
||||||
|
|
||||||
The system tries to be as flexible as possible, so many different information sources can be used.
|
The system tries to be as flexible as possible, so many different information sources can be used.
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,8 @@
|
||||||
"exports-loader": "^5.0.0",
|
"exports-loader": "^5.0.0",
|
||||||
"json-formatter-js": "^2.3.4",
|
"json-formatter-js": "^2.3.4",
|
||||||
"jszip": "^3.2.0",
|
"jszip": "^3.2.0",
|
||||||
"katex": "^0.16.0",
|
"katex": "^0.17.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^18.0.0",
|
||||||
"marked-gfm-heading-id": "^4.1.1",
|
"marked-gfm-heading-id": "^4.1.1",
|
||||||
"marked-mangle": "^1.0.1",
|
"marked-mangle": "^1.0.1",
|
||||||
"pdfmake": "^0.3.7",
|
"pdfmake": "^0.3.7",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated on Mon May 4 05:40:05 UTC 2026
|
# Generated on Mon Jun 1 07:07:44 UTC 2026
|
||||||
# This file contains all footprints available in the offical KiCAD library
|
# This file contains all footprints available in the offical KiCAD library
|
||||||
Audio_Module:Reverb_BTDR-1H
|
Audio_Module:Reverb_BTDR-1H
|
||||||
Audio_Module:Reverb_BTDR-1V
|
Audio_Module:Reverb_BTDR-1V
|
||||||
|
|
@ -11883,6 +11883,8 @@ Package_DFN_QFN:Texas_VQFN-HR-20_3x2.5mm_P0.5mm_RQQ0011A
|
||||||
Package_DFN_QFN:Texas_VQFN-RHL-20
|
Package_DFN_QFN:Texas_VQFN-RHL-20
|
||||||
Package_DFN_QFN:Texas_VQFN-RHL-20_ThermalVias
|
Package_DFN_QFN:Texas_VQFN-RHL-20_ThermalVias
|
||||||
Package_DFN_QFN:Texas_VQFN-RNR0011A-11
|
Package_DFN_QFN:Texas_VQFN-RNR0011A-11
|
||||||
|
Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm
|
||||||
|
Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm_ThermalVias
|
||||||
Package_DFN_QFN:Texas_WQFN-MR-100_3x3-DapStencil
|
Package_DFN_QFN:Texas_WQFN-MR-100_3x3-DapStencil
|
||||||
Package_DFN_QFN:Texas_WQFN-MR-100_ThermalVias_3x3-DapStencil
|
Package_DFN_QFN:Texas_WQFN-MR-100_ThermalVias_3x3-DapStencil
|
||||||
Package_DFN_QFN:Texas_X2QFN-12_1.6x1.6mm_P0.4mm
|
Package_DFN_QFN:Texas_X2QFN-12_1.6x1.6mm_P0.4mm
|
||||||
|
|
@ -12956,6 +12958,7 @@ Package_SON:MPS_VSON-6_1x1.5mm_P0.5mm
|
||||||
Package_SON:MicroCrystal_C7_SON-8_1.5x3.2mm_P0.9mm
|
Package_SON:MicroCrystal_C7_SON-8_1.5x3.2mm_P0.9mm
|
||||||
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm
|
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm
|
||||||
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm_ThermalVias
|
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm_ThermalVias
|
||||||
|
Package_SON:NXP_LSON-16-1EP_3.5x4.5mm_P0.5mm_EP2x3.8mm
|
||||||
Package_SON:NXP_XSON-16
|
Package_SON:NXP_XSON-16
|
||||||
Package_SON:Nexperia_HUSON-12_USON-12-1EP_1.35x2.5mm_P0.4mm_EP0.4x2mm
|
Package_SON:Nexperia_HUSON-12_USON-12-1EP_1.35x2.5mm_P0.4mm_EP0.4x2mm
|
||||||
Package_SON:Nexperia_HUSON-16_USON-16-1EP_1.35x3.3mm_P0.4mm_EP0.4x2.8mm
|
Package_SON:Nexperia_HUSON-16_USON-16-1EP_1.35x3.3mm_P0.4mm_EP0.4x2.8mm
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated on Mon May 4 05:40:43 UTC 2026
|
# Generated on Mon Jun 1 07:08:24 UTC 2026
|
||||||
# This file contains all symbols available in the offical KiCAD library
|
# This file contains all symbols available in the offical KiCAD library
|
||||||
4xxx:14528
|
4xxx:14528
|
||||||
4xxx:14529
|
4xxx:14529
|
||||||
|
|
@ -2255,11 +2255,11 @@ Battery_Management:BQ76200PW
|
||||||
Battery_Management:BQ76920PW
|
Battery_Management:BQ76920PW
|
||||||
Battery_Management:BQ76930DBT
|
Battery_Management:BQ76930DBT
|
||||||
Battery_Management:BQ76940DBT
|
Battery_Management:BQ76940DBT
|
||||||
Battery_Management:BQ7695201PFBR
|
Battery_Management:BQ7695201PFB
|
||||||
Battery_Management:BQ7695202PFBR
|
Battery_Management:BQ7695202PFB
|
||||||
Battery_Management:BQ7695203PFBR
|
Battery_Management:BQ7695203PFB
|
||||||
Battery_Management:BQ7695204PFBR
|
Battery_Management:BQ7695204PFB
|
||||||
Battery_Management:BQ76952PFBR
|
Battery_Management:BQ76952PFB
|
||||||
Battery_Management:BQ78350DBT
|
Battery_Management:BQ78350DBT
|
||||||
Battery_Management:BQ78350DBT-R1
|
Battery_Management:BQ78350DBT-R1
|
||||||
Battery_Management:CN3063
|
Battery_Management:CN3063
|
||||||
|
|
@ -7545,6 +7545,7 @@ Driver_FET:ZXGD3003E6
|
||||||
Driver_FET:ZXGD3004E6
|
Driver_FET:ZXGD3004E6
|
||||||
Driver_FET:ZXGD3006E6
|
Driver_FET:ZXGD3006E6
|
||||||
Driver_FET:ZXGD3009E6
|
Driver_FET:ZXGD3009E6
|
||||||
|
Driver_LED:AL5819W6
|
||||||
Driver_LED:AL8860MP
|
Driver_LED:AL8860MP
|
||||||
Driver_LED:AL8860WT
|
Driver_LED:AL8860WT
|
||||||
Driver_LED:AP3019AKTR
|
Driver_LED:AP3019AKTR
|
||||||
|
|
@ -7692,6 +7693,7 @@ Driver_Motor:DRV8311P
|
||||||
Driver_Motor:DRV8311S
|
Driver_Motor:DRV8311S
|
||||||
Driver_Motor:DRV8412
|
Driver_Motor:DRV8412
|
||||||
Driver_Motor:DRV8432
|
Driver_Motor:DRV8432
|
||||||
|
Driver_Motor:DRV8434PWP
|
||||||
Driver_Motor:DRV8461SPWP
|
Driver_Motor:DRV8461SPWP
|
||||||
Driver_Motor:DRV8662
|
Driver_Motor:DRV8662
|
||||||
Driver_Motor:DRV8800PWP
|
Driver_Motor:DRV8800PWP
|
||||||
|
|
@ -16035,6 +16037,7 @@ Power_Supervisor:TPS3831
|
||||||
Power_Supervisor:TPS3839DBZ
|
Power_Supervisor:TPS3839DBZ
|
||||||
Power_Supervisor:TPS3839DQN
|
Power_Supervisor:TPS3839DQN
|
||||||
RF:0900PC15J0013
|
RF:0900PC15J0013
|
||||||
|
RF:AD8302xRU
|
||||||
RF:ADC-10-1R
|
RF:ADC-10-1R
|
||||||
RF:ADCH-80
|
RF:ADCH-80
|
||||||
RF:ADCH-80A
|
RF:ADCH-80A
|
||||||
|
|
@ -19322,6 +19325,9 @@ Regulator_Switching:MAX15062C
|
||||||
Regulator_Switching:MAX1522
|
Regulator_Switching:MAX1522
|
||||||
Regulator_Switching:MAX1523
|
Regulator_Switching:MAX1523
|
||||||
Regulator_Switching:MAX1524
|
Regulator_Switching:MAX1524
|
||||||
|
Regulator_Switching:MAX15462A
|
||||||
|
Regulator_Switching:MAX15462B
|
||||||
|
Regulator_Switching:MAX15462C
|
||||||
Regulator_Switching:MAX17501AxTB
|
Regulator_Switching:MAX17501AxTB
|
||||||
Regulator_Switching:MAX17501BxTB
|
Regulator_Switching:MAX17501BxTB
|
||||||
Regulator_Switching:MAX17501ExTB
|
Regulator_Switching:MAX17501ExTB
|
||||||
|
|
@ -21301,6 +21307,7 @@ Timer_RTC:MCP79512-xMS
|
||||||
Timer_RTC:MCP79520-xMS
|
Timer_RTC:MCP79520-xMS
|
||||||
Timer_RTC:MCP79521-xMS
|
Timer_RTC:MCP79521-xMS
|
||||||
Timer_RTC:MCP79522-xMS
|
Timer_RTC:MCP79522-xMS
|
||||||
|
Timer_RTC:PCA2131
|
||||||
Timer_RTC:PCF85063ATL
|
Timer_RTC:PCF85063ATL
|
||||||
Timer_RTC:PCF8523T
|
Timer_RTC:PCF8523T
|
||||||
Timer_RTC:PCF8523TK
|
Timer_RTC:PCF8523TK
|
||||||
|
|
|
||||||
139
src/Controller/BrowserPluginController.php
Normal file
139
src/Controller/BrowserPluginController.php
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ use App\Entity\Parts\Part;
|
||||||
use App\Exceptions\OAuthReconnectRequiredException;
|
use App\Exceptions\OAuthReconnectRequiredException;
|
||||||
use App\Form\InfoProviderSystem\FromURLFormType;
|
use App\Form\InfoProviderSystem\FromURLFormType;
|
||||||
use App\Form\InfoProviderSystem\PartSearchType;
|
use App\Form\InfoProviderSystem\PartSearchType;
|
||||||
|
use App\Services\InfoProviderSystem\SubmittedPageStorage;
|
||||||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||||
|
|
@ -62,7 +63,8 @@ class InfoProviderController extends AbstractController
|
||||||
private readonly PartInfoRetriever $infoRetriever,
|
private readonly PartInfoRetriever $infoRetriever,
|
||||||
private readonly ExistingPartFinder $existingPartFinder,
|
private readonly ExistingPartFinder $existingPartFinder,
|
||||||
private readonly SettingsManagerInterface $settingsManager,
|
private readonly SettingsManagerInterface $settingsManager,
|
||||||
private readonly SettingsFormFactoryInterface $settingsFormFactory
|
private readonly SettingsFormFactoryInterface $settingsFormFactory,
|
||||||
|
private readonly SubmittedPageStorage $browserHtmlStorage,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
@ -221,7 +223,7 @@ class InfoProviderController extends AbstractController
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/from_url', name: 'info_providers_from_url')]
|
#[Route('/from_url', name: 'info_providers_from_url')]
|
||||||
public function fromURL(Request $request, GenericWebProvider $provider, CreateFromUrlHelper $fromUrlHelper): Response
|
public function fromURL(Request $request, CreateFromUrlHelper $fromUrlHelper): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||||
|
|
||||||
|
|
@ -242,6 +244,12 @@ class InfoProviderController extends AbstractController
|
||||||
$no_cache = $form->get('no_cache')->getData();
|
$no_cache = $form->get('no_cache')->getData();
|
||||||
$skip_delegation = $form->get('skip_delegation')->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 {
|
try {
|
||||||
//It's okay if we use the cached results here, as its just for convenience
|
//It's okay if we use the cached results here, as its just for convenience
|
||||||
$searchResult = $this->infoRetriever->searchByKeyword(
|
$searchResult = $this->infoRetriever->searchByKeyword(
|
||||||
|
|
@ -249,6 +257,7 @@ class InfoProviderController extends AbstractController
|
||||||
providers: [$method],
|
providers: [$method],
|
||||||
options: [
|
options: [
|
||||||
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
|
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
|
||||||
|
InfoProviderInterface::OPTION_SUBMITTED_PAGE_TOKEN => $submittedPageToken,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -262,6 +271,7 @@ class InfoProviderController extends AbstractController
|
||||||
'providerId' => $searchResult->provider_id,
|
'providerId' => $searchResult->provider_id,
|
||||||
'no_cache' => $no_cache ? 1 : null,
|
'no_cache' => $no_cache ? 1 : null,
|
||||||
'skip_delegation' => $skip_delegation ? 1 : null,
|
'skip_delegation' => $skip_delegation ? 1 : null,
|
||||||
|
'submitted_page_token' => $submittedPageToken ?: null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (ExceptionInterface $e) {
|
} catch (ExceptionInterface $e) {
|
||||||
|
|
@ -272,6 +282,7 @@ class InfoProviderController extends AbstractController
|
||||||
return $this->render('info_providers/from_url/from_url.html.twig', [
|
return $this->render('info_providers/from_url/from_url.html.twig', [
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
'partDetail' => $partDetail,
|
'partDetail' => $partDetail,
|
||||||
|
'recentBrowserPages' => $this->browserHtmlStorage->getRecentPages(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,10 +328,12 @@ 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
|
//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);
|
$no_cache = $request->query->getBoolean('no_cache', false);
|
||||||
$skip_delegation = $request->query->getBoolean('skip_delegation', false);
|
$skip_delegation = $request->query->getBoolean('skip_delegation', false);
|
||||||
|
$submitted_page_token = $request->query->getString('submitted_page_token');
|
||||||
|
|
||||||
$dto = $infoRetriever->getDetails($providerKey, $providerId, [
|
$dto = $infoRetriever->getDetails($providerKey, $providerId, [
|
||||||
InfoProviderInterface::OPTION_NO_CACHE => $no_cache,
|
InfoProviderInterface::OPTION_NO_CACHE => $no_cache,
|
||||||
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
|
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
|
||||||
|
InfoProviderInterface::OPTION_SUBMITTED_PAGE_TOKEN => $submitted_page_token,
|
||||||
]);
|
]);
|
||||||
$new_part = $infoRetriever->dtoToPart($dto);
|
$new_part = $infoRetriever->dtoToPart($dto);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ResetType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use App\Settings\AppSettings;
|
use App\Settings\AppSettings;
|
||||||
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
|
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
|
||||||
|
|
@ -50,7 +51,9 @@ class SettingsController extends AbstractController
|
||||||
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
||||||
|
|
||||||
//Create a form builder for the settings object
|
//Create a form builder for the settings object
|
||||||
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings);
|
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings, formOptions: [
|
||||||
|
'warn_on_unsaved_changes' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
//Add a submit button to the form
|
//Add a submit button to the form
|
||||||
$builder->add('submit', SubmitType::class, ['label' => 'save']);
|
$builder->add('submit', SubmitType::class, ['label' => 'save']);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ use App\Services\EntityURLGenerator;
|
||||||
use App\Services\Formatters\AmountFormatter;
|
use App\Services\Formatters\AmountFormatter;
|
||||||
use App\Services\Formatters\MoneyFormatter;
|
use App\Services\Formatters\MoneyFormatter;
|
||||||
use App\Services\ProjectSystem\ProjectBuildHelper;
|
use App\Services\ProjectSystem\ProjectBuildHelper;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
use Brick\Math\RoundingMode;
|
use Brick\Math\RoundingMode;
|
||||||
use Doctrine\ORM\AbstractQuery;
|
use Doctrine\ORM\AbstractQuery;
|
||||||
use Doctrine\ORM\Query;
|
use Doctrine\ORM\Query;
|
||||||
|
|
@ -93,14 +94,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
|
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->add('partId', TextColumn::class, [
|
->add('partId', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('project.bom.part_id'),
|
'label' => $this->translator->trans('project.bom.part_id'),
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'orderField' => 'part.id',
|
'orderField' => 'part.id',
|
||||||
'render' => function ($value, ProjectBOMEntry $context) {
|
'render' => function ($value, ProjectBOMEntry $context) {
|
||||||
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
|
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->add('name', TextColumn::class, [
|
->add('name', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.name'),
|
'label' => $this->translator->trans('part.table.name'),
|
||||||
'orderField' => 'NATSORT(part.name)',
|
'orderField' => 'NATSORT(part.name)',
|
||||||
|
|
@ -161,7 +162,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
||||||
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
|
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
|
||||||
'orderField' => 'part.manufacturing_status',
|
'orderField' => 'part.manufacturing_status',
|
||||||
'class' => ManufacturingStatus::class,
|
'class' => ManufacturingStatus::class,
|
||||||
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
||||||
if ($status === null) {
|
if ($status === null) {
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -212,7 +213,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
'visible' => false,
|
'visible' => false,
|
||||||
'render' => function ($value, ProjectBOMEntry $context) {
|
'render' => function ($value, ProjectBOMEntry $context) {
|
||||||
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
||||||
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true);
|
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::Up)->toFloat(), null, 2, true);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->add('ext_price', TextColumn::class, [
|
->add('ext_price', TextColumn::class, [
|
||||||
|
|
@ -221,7 +222,8 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
'render' => function ($value, ProjectBOMEntry $context) {
|
'render' => function ($value, ProjectBOMEntry $context) {
|
||||||
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
||||||
return $this->moneyFormatter->format(
|
return $this->moneyFormatter->format(
|
||||||
$price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(),
|
$price->multipliedBy(BigDecimal::fromFloatShortest($context->getQuantity()))
|
||||||
|
->toScale(2, RoundingMode::Up)->toFloat(),
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
true
|
true
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ class BigDecimalType extends Type
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return BigDecimal::of($value);
|
return BigDecimal::of(is_float($value) ? BigDecimal::fromFloatShortest($value) : $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ class Currency extends AbstractStructuralDBElement
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BigDecimal::one()->dividedBy($tmp, $tmp->getScale(), RoundingMode::HALF_UP);
|
return BigDecimal::one()->dividedBy($tmp, $tmp->getScale(), RoundingMode::HalfUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -195,10 +195,10 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
||||||
#[SerializedName('price_per_unit')]
|
#[SerializedName('price_per_unit')]
|
||||||
public function getPricePerUnit(float|string|BigDecimal $multiplier = 1.0): BigDecimal
|
public function getPricePerUnit(float|string|BigDecimal $multiplier = 1.0): BigDecimal
|
||||||
{
|
{
|
||||||
$tmp = BigDecimal::of($multiplier);
|
$tmp = is_float($multiplier) ? BigDecimal::fromFloatShortest($multiplier) : BigDecimal::of($multiplier);
|
||||||
$tmp = $tmp->multipliedBy($this->price);
|
$tmp = $tmp->multipliedBy($this->price);
|
||||||
|
|
||||||
return $tmp->dividedBy($this->price_related_quantity, static::PRICE_PRECISION, RoundingMode::HALF_UP);
|
return $tmp->dividedBy(BigDecimal::fromFloatShortest($this->price_related_quantity), static::PRICE_PRECISION, RoundingMode::HalfUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -317,7 +317,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
||||||
*/
|
*/
|
||||||
public function setPrice(BigDecimal $new_price): self
|
public function setPrice(BigDecimal $new_price): self
|
||||||
{
|
{
|
||||||
$tmp = $new_price->toScale(self::PRICE_PRECISION, RoundingMode::HALF_UP);
|
$tmp = $new_price->toScale(self::PRICE_PRECISION, RoundingMode::HalfUp);
|
||||||
//Only change the object, if the value changes, so that doctrine does not detect it as changed.
|
//Only change the object, if the value changes, so that doctrine does not detect it as changed.
|
||||||
if ((string) $tmp !== (string) $this->price) {
|
if ((string) $tmp !== (string) $this->price) {
|
||||||
$this->price = $tmp;
|
$this->price = $tmp;
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ class BaseEntityAdminForm extends AbstractType
|
||||||
$resolver->setRequired('attachment_class');
|
$resolver->setRequired('attachment_class');
|
||||||
$resolver->setRequired('parameter_class');
|
$resolver->setRequired('parameter_class');
|
||||||
$resolver->setAllowedTypes('parameter_class', ['string', 'null']);
|
$resolver->setAllowedTypes('parameter_class', ['string', 'null']);
|
||||||
|
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'warn_on_unsaved_changes' => true,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
|
|
||||||
84
src/Form/Extension/UnsavedChangesExtension.php
Normal file
84
src/Form/Extension/UnsavedChangesExtension.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2025 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\Form\Extension;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractTypeExtension;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\Form\FormView;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a `warn_on_unsaved_changes` option to any root form. When set to true, the Stimulus
|
||||||
|
* `common--dirty-form` controller attributes are merged into the form element's HTML
|
||||||
|
* attributes, enabling unsaved-change detection without any template boilerplate.
|
||||||
|
*
|
||||||
|
* Usage in a form type:
|
||||||
|
*
|
||||||
|
* public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
* {
|
||||||
|
* $resolver->setDefaults(['warn_on_unsaved_changes' => true]);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Or per-instance from a controller:
|
||||||
|
*
|
||||||
|
* $form = $this->createForm(MyFormType::class, $data, ['warn_on_unsaved_changes' => true]);
|
||||||
|
*/
|
||||||
|
class UnsavedChangesExtension extends AbstractTypeExtension
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TranslatorInterface $translator)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getExtendedTypes(): iterable
|
||||||
|
{
|
||||||
|
return [FormType::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefault('warn_on_unsaved_changes', false);
|
||||||
|
$resolver->setAllowedTypes('warn_on_unsaved_changes', 'bool');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||||
|
{
|
||||||
|
if (!$options['warn_on_unsaved_changes'] || $view->parent !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extraAttr = [
|
||||||
|
'data-controller' => 'common--dirty-form',
|
||||||
|
'data-common--dirty-form-confirm-title-value' => $this->translator->trans('form.dirty_form.unsaved_changes.title'),
|
||||||
|
'data-common--dirty-form-confirm-message-value' => $this->translator->trans('form.dirty_form.unsaved_changes.message'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Merge data-action so existing actions on the form element are preserved.
|
||||||
|
$existingAction = $view->vars['attr']['data-action'] ?? '';
|
||||||
|
$dirtyActions = 'submit->common--dirty-form#submit reset->common--dirty-form#resetDirtyState';
|
||||||
|
$extraAttr['data-action'] = $existingAction !== '' ? $existingAction . ' ' . $dirtyActions : $dirtyActions;
|
||||||
|
|
||||||
|
$view->vars['attr'] = array_merge($view->vars['attr'], $extraAttr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -353,6 +353,7 @@ class PartBaseType extends AbstractType
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => Part::class,
|
'data_class' => Part::class,
|
||||||
'info_provider_dto' => null,
|
'info_provider_dto' => null,
|
||||||
|
'warn_on_unsaved_changes' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']);
|
$resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,16 @@ class BigDecimalMoneyType extends AbstractType implements DataTransformerInterfa
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BigDecimal::of($value);
|
if ($value instanceof BigDecimal) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_float($value)) {
|
||||||
|
return BigDecimal::fromFloatShortest($value);
|
||||||
|
}
|
||||||
|
if (is_string($value)) {
|
||||||
|
return BigDecimal::of($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException(sprintf('Expected a string, float or BigDecimal, got %s', get_debug_type($value)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,17 @@ class BigDecimalNumberType extends AbstractType implements DataTransformerInterf
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BigDecimal::of($value);
|
if ($value instanceof BigDecimal) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_float($value)) {
|
||||||
|
return BigDecimal::fromFloatShortest($value);
|
||||||
|
}
|
||||||
|
if (is_string($value)) {
|
||||||
|
return BigDecimal::of($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException(sprintf('Expected a string, float or BigDecimal, got %s', get_debug_type($value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ class UserAdminForm extends AbstractType
|
||||||
$resolver->setDefault('parameter_class', false);
|
$resolver->setDefault('parameter_class', false);
|
||||||
|
|
||||||
$resolver->setDefault('validation_groups', ['Default', 'permissions:edit']);
|
$resolver->setDefault('validation_groups', ['Default', 'permissions:edit']);
|
||||||
|
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'warn_on_unsaved_changes' => true,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,13 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Serializer\APIPlatform;
|
namespace App\Serializer\APIPlatform;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
|
||||||
|
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
|
||||||
|
use ApiPlatform\Metadata\IriConverterInterface;
|
||||||
use ApiPlatform\Serializer\ItemNormalizer;
|
use ApiPlatform\Serializer\ItemNormalizer;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||||
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
|
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
|
||||||
|
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
use Symfony\Component\Serializer\SerializerAwareInterface;
|
use Symfony\Component\Serializer\SerializerAwareInterface;
|
||||||
|
|
@ -35,6 +39,10 @@ use Symfony\Component\Serializer\SerializerInterface;
|
||||||
* This class decorates API Platform's ItemNormalizer to allow skipping the normalization process by setting the
|
* This class decorates API Platform's ItemNormalizer to allow skipping the normalization process by setting the
|
||||||
* DISABLE_ITEM_NORMALIZER context key to true. This is useful for all kind of serialization operations, where the API
|
* DISABLE_ITEM_NORMALIZER context key to true. This is useful for all kind of serialization operations, where the API
|
||||||
* Platform subsystem should not be used.
|
* Platform subsystem should not be used.
|
||||||
|
*
|
||||||
|
* It also works around a bug in API Platform's AbstractItemNormalizer where IRI strings for abstract resource classes
|
||||||
|
* with a discriminator map fail deserialization when objectToPopulate is null (the discriminator is checked before
|
||||||
|
* the IRI string check). See: https://github.com/Part-DB/Part-DB-server/issues/1370
|
||||||
*/
|
*/
|
||||||
#[AsDecorator("api_platform.serializer.normalizer.item")]
|
#[AsDecorator("api_platform.serializer.normalizer.item")]
|
||||||
class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
|
class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
|
||||||
|
|
@ -42,13 +50,44 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
|
||||||
|
|
||||||
public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER';
|
public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER';
|
||||||
|
|
||||||
public function __construct(private readonly ItemNormalizer $inner)
|
public function __construct(
|
||||||
{
|
private readonly ItemNormalizer $inner,
|
||||||
|
private readonly IriConverterInterface $iriConverter,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
|
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
|
||||||
{
|
{
|
||||||
|
// API Platform's AbstractItemNormalizer has a bug: when objectToPopulate is null and data is an IRI
|
||||||
|
// string, it tries to resolve the discriminator class from [$iri_string] before reaching the IRI
|
||||||
|
// check (line 271). For abstract resource classes with a discriminator map (e.g. Attachment), this
|
||||||
|
// fails because the array has no _type key. Fix by resolving IRI strings directly.
|
||||||
|
// See: https://github.com/Part-DB/Part-DB-server/issues/1370
|
||||||
|
if (is_string($data) || (is_array($data) && isset($data['@id']) && is_string($data['@id']))) {
|
||||||
|
if (is_array($data)) {
|
||||||
|
$iri = $data['@id'];
|
||||||
|
} else {
|
||||||
|
$iri = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]);
|
||||||
|
} catch (ItemNotFoundException $e) {
|
||||||
|
if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isset($context['not_normalizable_value_exceptions'])) {
|
||||||
|
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
|
||||||
|
}
|
||||||
|
throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
if (!isset($context['not_normalizable_value_exceptions'])) {
|
||||||
|
throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $iri), $e->getCode(), $e);
|
||||||
|
}
|
||||||
|
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Invalid IRI "%s".', $data), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $this->inner->denormalize($data, $type, $format, $context);
|
return $this->inner->denormalize($data, $type, $format, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,4 +126,4 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
|
||||||
'object' => false
|
'object' => false
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,8 @@ class AttachmentManager
|
||||||
//Taken from: https://www.php.net/manual/de/function.filesize.php#106569 and slightly modified
|
//Taken from: https://www.php.net/manual/de/function.filesize.php#106569 and slightly modified
|
||||||
|
|
||||||
$sz = 'BKMGTP';
|
$sz = 'BKMGTP';
|
||||||
$factor = (int) floor((strlen((string) $bytes) - 1) / 3);
|
$factor = min((int) floor((strlen((string) $bytes) - 1) / 3), strlen($sz) - 1);
|
||||||
//Use real (10 based) SI prefixes
|
//Use real (10 based) SI prefixes
|
||||||
return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).@$sz[$factor];
|
return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).$sz[$factor];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,10 @@ class SIFormatter
|
||||||
$prefixes_neg = ['', 'm', 'μ', 'n', 'p', 'f', 'a', 'z', 'y'];
|
$prefixes_neg = ['', 'm', 'μ', 'n', 'p', 'f', 'a', 'z', 'y'];
|
||||||
|
|
||||||
if ($magnitude >= 0) {
|
if ($magnitude >= 0) {
|
||||||
$nearest = (int) floor(abs($magnitude) / 3);
|
$nearest = min((int) floor(abs($magnitude) / 3), count($prefixes_pos) - 1);
|
||||||
$symbol = $prefixes_pos[$nearest];
|
$symbol = $prefixes_pos[$nearest];
|
||||||
} else {
|
} else {
|
||||||
$nearest = (int) round(abs($magnitude) / 3);
|
$nearest = min((int) round(abs($magnitude) / 3), count($prefixes_neg) - 1);
|
||||||
$symbol = $prefixes_neg[$nearest];
|
$symbol = $prefixes_neg[$nearest];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ trait PKImportHelperTrait
|
||||||
//Use mime type to determine the extension like PartKeepr does in legacy implementation (just use the second part of the mime type)
|
//Use mime type to determine the extension like PartKeepr does in legacy implementation (just use the second part of the mime type)
|
||||||
//See UploadedFile.php:291 in PartKeepr (https://github.com/partkeepr/PartKeepr/blob/f6176c3354b24fa39ac8bc4328ee0df91de3d5b6/src/PartKeepr/UploadedFileBundle/Entity/UploadedFile.php#L291)
|
//See UploadedFile.php:291 in PartKeepr (https://github.com/partkeepr/PartKeepr/blob/f6176c3354b24fa39ac8bc4328ee0df91de3d5b6/src/PartKeepr/UploadedFileBundle/Entity/UploadedFile.php#L291)
|
||||||
if (!empty ($attachment_row['mimetype'])) {
|
if (!empty ($attachment_row['mimetype'])) {
|
||||||
$attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1];
|
$attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1] ?? '';
|
||||||
} else {
|
} else {
|
||||||
//If the mime type is empty, we use the original extension
|
//If the mime type is empty, we use the original extension
|
||||||
$attachment_row['extension'] = pathinfo((string) $attachment_row['originalname'], PATHINFO_EXTENSION);
|
$attachment_row['extension'] = pathinfo((string) $attachment_row['originalname'], PATHINFO_EXTENSION);
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,7 @@ class PKPartImporter
|
||||||
//Partkeepr stores the price per item, we need to convert it to the price per packaging unit
|
//Partkeepr stores the price per item, we need to convert it to the price per packaging unit
|
||||||
$price_per_item = BigDecimal::of($partdistributor['price']);
|
$price_per_item = BigDecimal::of($partdistributor['price']);
|
||||||
$packaging_unit = (float) ($partdistributor['packagingUnit'] ?? 1);
|
$packaging_unit = (float) ($partdistributor['packagingUnit'] ?? 1);
|
||||||
$pricedetail->setPrice($price_per_item->multipliedBy($packaging_unit));
|
$pricedetail->setPrice($price_per_item->multipliedBy(BigDecimal::fromFloatShortest($packaging_unit)));
|
||||||
$pricedetail->setPriceRelatedQuantity($packaging_unit);
|
$pricedetail->setPriceRelatedQuantity($packaging_unit);
|
||||||
//We have to set the minimum discount quantity to the packaging unit (PartKeepr does not know this concept)
|
//We have to set the minimum discount quantity to the packaging unit (PartKeepr does not know this concept)
|
||||||
//But in Part-DB the minimum discount qty have to be unique across a orderdetail
|
//But in Part-DB the minimum discount qty have to be unique across a orderdetail
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -175,15 +175,15 @@ final class PartInfoRetriever
|
||||||
*/
|
*/
|
||||||
public function dtoToPart(PartDetailDTO $search_result): Part
|
public function dtoToPart(PartDetailDTO $search_result): Part
|
||||||
{
|
{
|
||||||
return $this->createPart($search_result->provider_key, $search_result->provider_id);
|
return $this->dto_to_entity_converter->convertPart($search_result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use the given details to create a part entity
|
* Use the given details to create a part entity
|
||||||
*/
|
*/
|
||||||
public function createPart(string $provider_key, string $part_id): Part
|
public function createPart(string $provider_key, string $part_id, array $options): Part
|
||||||
{
|
{
|
||||||
$details = $this->getDetails($provider_key, $part_id);
|
$details = $this->getDetails($provider_key, $part_id, $options);
|
||||||
|
|
||||||
return $this->dto_to_entity_converter->convertPart($details);
|
return $this->dto_to_entity_converter->convertPart($details);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,11 @@ namespace App\Services\InfoProviderSystem\Providers;
|
||||||
use App\Exceptions\ProviderIDNotSupportedException;
|
use App\Exceptions\ProviderIDNotSupportedException;
|
||||||
use App\Helpers\RandomizeUseragentHttpClient;
|
use App\Helpers\RandomizeUseragentHttpClient;
|
||||||
use App\Services\AI\AIPlatformRegistry;
|
use App\Services\AI\AIPlatformRegistry;
|
||||||
|
use App\Services\InfoProviderSystem\SubmittedPageStorage;
|
||||||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||||
use App\Services\InfoProviderSystem\DTOJsonSchemaConverter;
|
use App\Services\InfoProviderSystem\DTOJsonSchemaConverter;
|
||||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||||
use App\Settings\InfoProviderSystem\AIExtractorSettings;
|
use App\Settings\InfoProviderSystem\AIExtractorSettings;
|
||||||
use Brick\Schema\SchemaReader;
|
|
||||||
use Imagine\Image\Format;
|
|
||||||
use Jkphl\Micrometa;
|
use Jkphl\Micrometa;
|
||||||
use League\HTMLToMarkdown\HtmlConverter;
|
use League\HTMLToMarkdown\HtmlConverter;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
|
@ -62,6 +61,7 @@ final class AIWebProvider implements InfoProviderInterface
|
||||||
private readonly DTOJsonSchemaConverter $jsonSchemaConverter,
|
private readonly DTOJsonSchemaConverter $jsonSchemaConverter,
|
||||||
private readonly CacheItemPoolInterface $partInfoCache,
|
private readonly CacheItemPoolInterface $partInfoCache,
|
||||||
private readonly CreateFromUrlHelper $createFromUrlHelper,
|
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
|
//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(
|
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
|
||||||
|
|
@ -142,9 +142,17 @@ final class AIWebProvider implements InfoProviderInterface
|
||||||
return $cacheItem->get();
|
return $cacheItem->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch HTML content
|
// Use pre-fetched browser HTML if the option is set and a stored page is available for this URL
|
||||||
$response = $this->httpClient->request('GET', $url);
|
$html = null;
|
||||||
$html = $response->getContent();
|
if (($token = ($options[self::OPTION_SUBMITTED_PAGE_TOKEN] ?? '')) !== '') {
|
||||||
|
$html = $this->browserHtmlStorage->retrieve($token)?->html;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Otherwise fetch it ourselves.
|
||||||
|
if ($html === null) {
|
||||||
|
$response = $this->httpClient->request('GET', $url);
|
||||||
|
$html = $response->getContent();
|
||||||
|
}
|
||||||
|
|
||||||
//Convert html to markdown, to provide a cleaner input to the LLM.
|
//Convert html to markdown, to provide a cleaner input to the LLM.
|
||||||
$markdown = $this->htmlToMarkdown($html, $url);
|
$markdown = $this->htmlToMarkdown($html, $url);
|
||||||
|
|
@ -176,9 +184,20 @@ final class AIWebProvider implements InfoProviderInterface
|
||||||
*/
|
*/
|
||||||
private function extractStructuredData(string $html, string $url): string
|
private function extractStructuredData(string $html, string $url): string
|
||||||
{
|
{
|
||||||
//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
|
try {
|
||||||
$micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA | Micrometa\Ports\Format::RDFA_LITE);
|
//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
|
||||||
$items = $micrometa($url, $html);
|
$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);
|
return json_encode($items->toObject(), JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers;
|
||||||
|
|
||||||
use App\Exceptions\ProviderIDNotSupportedException;
|
use App\Exceptions\ProviderIDNotSupportedException;
|
||||||
use App\Helpers\RandomizeUseragentHttpClient;
|
use App\Helpers\RandomizeUseragentHttpClient;
|
||||||
|
use App\Services\InfoProviderSystem\SubmittedPageStorage;
|
||||||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||||
|
|
@ -57,6 +58,7 @@ class GenericWebProvider implements InfoProviderInterface
|
||||||
|
|
||||||
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings,
|
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings,
|
||||||
private readonly CreateFromUrlHelper $createFromUrlHelper,
|
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
|
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
|
||||||
|
|
@ -294,9 +296,17 @@ class GenericWebProvider implements InfoProviderInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Try to get the webpage content
|
// Use pre-fetched browser HTML if the option is set and a stored page is available for this URL
|
||||||
$response = $this->httpClient->request('GET', $url);
|
$content = null;
|
||||||
$content = $response->getContent();
|
if (($token = ($options[self::OPTION_SUBMITTED_PAGE_TOKEN] ?? '')) !== '') {
|
||||||
|
$content = $this->browserHtmlStorage->retrieve($token)?->html;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Otherwise, fetch the page content ourselves
|
||||||
|
if ($content === null) {
|
||||||
|
$response = $this->httpClient->request('GET', $url);
|
||||||
|
$content = $response->getContent();
|
||||||
|
}
|
||||||
|
|
||||||
$dom = new Crawler($content);
|
$dom = new Crawler($content);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ 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_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_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
|
* Get information about this provider
|
||||||
|
|
|
||||||
131
src/Services/InfoProviderSystem/SubmittedPageStorage.php
Normal file
131
src/Services/InfoProviderSystem/SubmittedPageStorage.php
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -222,7 +222,7 @@ class TimeTravel
|
||||||
if (isset($metadata->fieldMappings[$field])) {
|
if (isset($metadata->fieldMappings[$field])) {
|
||||||
//We need to convert the string to a BigDecimal first
|
//We need to convert the string to a BigDecimal first
|
||||||
if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)->type)) {
|
if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)->type)) {
|
||||||
$data = BigDecimal::of($data);
|
$data = is_float($data) ? BigDecimal::fromFloatShortest($data) : BigDecimal::of($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$data instanceof \DateTimeInterface
|
if (!$data instanceof \DateTimeInterface
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ class PricedetailHelper
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $avg->dividedBy($count, Pricedetail::PRICE_PRECISION, RoundingMode::HALF_UP);
|
return $avg->dividedBy($count, Pricedetail::PRICE_PRECISION, RoundingMode::HalfUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -213,6 +213,6 @@ class PricedetailHelper
|
||||||
$val_target = $val_base->multipliedBy($targetCurrency->getInverseExchangeRate());
|
$val_target = $val_base->multipliedBy($targetCurrency->getInverseExchangeRate());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $val_target->toScale(Pricedetail::PRICE_PRECISION, RoundingMode::HALF_UP);
|
return $val_target->toScale(Pricedetail::PRICE_PRECISION, RoundingMode::HalfUp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ final readonly class ProjectBuildHelper
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$has_price = true;
|
$has_price = true;
|
||||||
$total = $total->plus($unit_price->multipliedBy($entry->getQuantity())->multipliedBy($number_of_builds));
|
$total = $total->plus($unit_price->multipliedBy(BigDecimal::fromFloatShortest($entry->getQuantity()))->multipliedBy($number_of_builds));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $has_price ? $total : null;
|
return $has_price ? $total : null;
|
||||||
|
|
@ -206,7 +206,7 @@ final readonly class ProjectBuildHelper
|
||||||
if ($total === null) {
|
if ($total === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return $total->dividedBy($number_of_builds, 10, RoundingMode::HALF_UP);
|
return $total->dividedBy($number_of_builds, 10, RoundingMode::HalfUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,7 +215,7 @@ final readonly class ProjectBuildHelper
|
||||||
public function roundedTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
public function roundedTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
||||||
{
|
{
|
||||||
return $this->calculateTotalBuildPrice($project, $number_of_builds, $currency)
|
return $this->calculateTotalBuildPrice($project, $number_of_builds, $currency)
|
||||||
?->toScale(2, RoundingMode::UP);
|
?->toScale(2, RoundingMode::Up);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -224,7 +224,7 @@ final readonly class ProjectBuildHelper
|
||||||
public function roundedUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
public function roundedUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
||||||
{
|
{
|
||||||
return $this->calculateUnitBuildPrice($project, $number_of_builds, $currency)
|
return $this->calculateUnitBuildPrice($project, $number_of_builds, $currency)
|
||||||
?->toScale(2, RoundingMode::UP);
|
?->toScale(2, RoundingMode::Up);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ final readonly class GitVersionInfoProvider
|
||||||
{
|
{
|
||||||
if (is_file($this->getGitDirectory() . '/HEAD')) {
|
if (is_file($this->getGitDirectory() . '/HEAD')) {
|
||||||
$git = file($this->getGitDirectory() . '/HEAD');
|
$git = file($this->getGitDirectory() . '/HEAD');
|
||||||
|
if ($git === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$head = explode('/', $git[0], 3);
|
$head = explode('/', $git[0], 3);
|
||||||
|
|
||||||
if (!isset($head[2])) {
|
if (!isset($head[2])) {
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,15 @@ class ExchangeRateUpdater
|
||||||
try {
|
try {
|
||||||
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
|
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
|
||||||
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
|
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
|
||||||
$effective_rate = BigDecimal::of($rate->getValue());
|
$effective_rate = BigDecimal::fromFloatShortest($rate->getValue());
|
||||||
} catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
|
} catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
|
||||||
//Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
|
//Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
|
||||||
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
|
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
|
||||||
//The rate says how many quote units are worth one base unit
|
//The rate says how many quote units are worth one base unit
|
||||||
//So we need to invert it to get the exchange rate
|
//So we need to invert it to get the exchange rate
|
||||||
|
|
||||||
$rate_bd = BigDecimal::of($rate->getValue());
|
$rate_bd = BigDecimal::fromFloatShortest($rate->getValue());
|
||||||
$effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
|
$effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HalfUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currency->setExchangeRate($effective_rate);
|
$currency->setExchangeRate($effective_rate);
|
||||||
|
|
|
||||||
40
src/Settings/InfoProviderSystem/BrowserPluginSettings.php
Normal file
40
src/Settings/InfoProviderSystem/BrowserPluginSettings.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,9 @@ class InfoProviderSettings
|
||||||
#[EmbeddedSettings]
|
#[EmbeddedSettings]
|
||||||
public ?InfoProviderGeneralSettings $general = null;
|
public ?InfoProviderGeneralSettings $general = null;
|
||||||
|
|
||||||
|
#[EmbeddedSettings]
|
||||||
|
public ?BrowserPluginSettings $browserPlugin = null;
|
||||||
|
|
||||||
#[EmbeddedSettings]
|
#[EmbeddedSettings]
|
||||||
public ?GenericWebProviderSettings $genericWebProvider = null;
|
public ?GenericWebProviderSettings $genericWebProvider = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,5 +33,31 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ form_row(form.submit) }}
|
{{ 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) }}
|
{{ form_end(form) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -69,4 +69,52 @@ final class PartEndpointTest extends CrudEndpointTestCase
|
||||||
{
|
{
|
||||||
$this->_testDeleteItem(1);
|
$this->_testDeleteItem(1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public function testMasterPictureAttachmentPatchWithIRI(): void
|
||||||
|
{
|
||||||
|
$client = static::createAuthenticatedClient();
|
||||||
|
|
||||||
|
// Create a new attachment with a picture URL for Part 1
|
||||||
|
$response = $client->request('POST', '/api/attachments', ['json' => [
|
||||||
|
'name' => 'Test Picture',
|
||||||
|
'url' => 'http://example.com/test.jpg',
|
||||||
|
'_type' => 'Part',
|
||||||
|
'element' => '/api/parts/1',
|
||||||
|
'attachment_type' => '/api/attachment_types/1',
|
||||||
|
]]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$attachmentIri = $response->toArray()['@id'];
|
||||||
|
|
||||||
|
// Now PATCH Part 1 to set master_picture_attachment
|
||||||
|
$client->request('PATCH', '/api/parts/1', [
|
||||||
|
'json' => ['master_picture_attachment' => $attachmentIri],
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMasterPictureAttachmentPatchWithArray(): void
|
||||||
|
{
|
||||||
|
$client = static::createAuthenticatedClient();
|
||||||
|
|
||||||
|
// Create a new attachment with a picture URL for Part 1
|
||||||
|
$response = $client->request('POST', '/api/attachments', ['json' => [
|
||||||
|
'name' => 'Test Picture',
|
||||||
|
'url' => 'http://example.com/test.jpg',
|
||||||
|
'_type' => 'Part',
|
||||||
|
'element' => '/api/parts/1',
|
||||||
|
'attachment_type' => '/api/attachment_types/1',
|
||||||
|
]]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$attachmentIri = $response->toArray()['@id'];
|
||||||
|
|
||||||
|
// Now PATCH Part 1 to set master_picture_attachment
|
||||||
|
$client->request('PATCH', '/api/parts/1', [
|
||||||
|
'json' => ['master_picture_attachment' => ['@id' => $attachmentIri, '_type' => 'Part']],
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
222
tests/Controller/AuthorizationTest.php
Normal file
222
tests/Controller/AuthorizationTest.php
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the HTTP access-control boundaries:
|
||||||
|
*
|
||||||
|
* The app has an "anonymous" fixture user with readonly permissions, so truly
|
||||||
|
* public read routes return 200 even without a session. Write-protected routes
|
||||||
|
* return 401 for unauthenticated requests (not a 302 redirect).
|
||||||
|
*
|
||||||
|
* Users: admin (all-allow), user (editor preset), noread (no group/no perms)
|
||||||
|
*/
|
||||||
|
#[Group('DB')]
|
||||||
|
#[Group('slow')]
|
||||||
|
final class AuthorizationTest extends WebTestCase
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Data providers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes readable by the anonymous user — unauthenticated requests get 200.
|
||||||
|
*/
|
||||||
|
public static function publicReadRoutesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'homepage' => ['/en/'];
|
||||||
|
yield 'part view' => ['/en/part/1'];
|
||||||
|
yield 'statistics' => ['/en/statistics'];
|
||||||
|
yield 'select category' => ['/en/select_api/category'];
|
||||||
|
yield 'typeahead tags' => ['/en/typeahead/tags/search/test'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write-protected routes — unauthenticated gets 401 (not 302).
|
||||||
|
*/
|
||||||
|
public static function writeProtectedRoutesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'part edit' => ['/en/part/1/edit'];
|
||||||
|
yield 'part new' => ['/en/part/new'];
|
||||||
|
yield 'user edit' => ['/en/user/1/edit'];
|
||||||
|
yield 'log list' => ['/en/log/'];
|
||||||
|
yield 'server info' => ['/en/tools/server_infos'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes the `noread` user (no group = no permissions) must be denied.
|
||||||
|
*/
|
||||||
|
public static function noreadDeniedRoutesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'part view' => ['/en/part/1'];
|
||||||
|
yield 'part edit' => ['/en/part/1/edit'];
|
||||||
|
yield 'part new' => ['/en/part/new'];
|
||||||
|
yield 'log list' => ['/en/log/'];
|
||||||
|
yield 'server info' => ['/en/tools/server_infos'];
|
||||||
|
yield 'select category' => ['/en/select_api/category'];
|
||||||
|
yield 'typeahead tags' => ['/en/typeahead/tags/search/test'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes the `user` (editor preset) must have access to.
|
||||||
|
*/
|
||||||
|
public static function editorAllowedRoutesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'homepage' => ['/en/'];
|
||||||
|
yield 'part view' => ['/en/part/1'];
|
||||||
|
yield 'part edit' => ['/en/part/1/edit'];
|
||||||
|
yield 'part new' => ['/en/part/new'];
|
||||||
|
yield 'select cat' => ['/en/select_api/category'];
|
||||||
|
yield 'typeahead' => ['/en/typeahead/tags/search/test'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only routes the `user` (editor) must be denied.
|
||||||
|
*/
|
||||||
|
public static function editorDeniedRoutesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'user edit' => ['/en/user/1/edit'];
|
||||||
|
yield 'log list' => ['/en/log/'];
|
||||||
|
yield 'server info' => ['/en/tools/server_infos'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes the `admin` user must be able to reach.
|
||||||
|
*/
|
||||||
|
public static function adminAllowedRoutesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'user edit' => ['/en/user/1/edit'];
|
||||||
|
yield 'log list' => ['/en/log/'];
|
||||||
|
yield 'server info' => ['/en/tools/server_infos'];
|
||||||
|
yield 'part view' => ['/en/part/1'];
|
||||||
|
yield 'part edit' => ['/en/part/1/edit'];
|
||||||
|
yield 'statistics' => ['/en/statistics'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private function loginAs(string $username): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$user = $em->getRepository(User::class)->findOneBy(['name' => $username]);
|
||||||
|
if ($user === null) {
|
||||||
|
$this->markTestSkipped("Fixture user '$username' not found.");
|
||||||
|
}
|
||||||
|
$client->loginUser($user);
|
||||||
|
$client->followRedirects(false);
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertDenied(KernelBrowser $client, string $url): void
|
||||||
|
{
|
||||||
|
$client->request('GET', $url);
|
||||||
|
$code = $client->getResponse()->getStatusCode();
|
||||||
|
$this->assertTrue(
|
||||||
|
$code === Response::HTTP_FORBIDDEN || $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(),
|
||||||
|
"Expected 401/403/redirect on $url, got $code"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Unauthenticated: public reads
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('publicReadRoutesProvider')]
|
||||||
|
public function testUnauthenticatedCanReadPublicRoutes(string $url): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', $url);
|
||||||
|
// Anonymous user (readonly group) can access read-only content
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Unauthenticated: write routes → 401
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('writeProtectedRoutesProvider')]
|
||||||
|
public function testUnauthenticatedIsUnauthorizedOnWriteRoutes(string $url): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->followRedirects(false);
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$code = $client->getResponse()->getStatusCode();
|
||||||
|
$this->assertTrue(
|
||||||
|
$code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(),
|
||||||
|
"Expected 401 or redirect on $url for unauthenticated request, got $code"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// noread user: denied everywhere
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('noreadDeniedRoutesProvider')]
|
||||||
|
public function testNoreadUserIsDenied(string $url): void
|
||||||
|
{
|
||||||
|
$this->assertDenied($this->loginAs('noread'), $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Editor user
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('editorAllowedRoutesProvider')]
|
||||||
|
public function testEditorCanAccess(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->loginAs('user');
|
||||||
|
$client->request('GET', $url);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('editorDeniedRoutesProvider')]
|
||||||
|
public function testEditorIsDeniedOnAdminRoutes(string $url): void
|
||||||
|
{
|
||||||
|
$this->assertDenied($this->loginAs('user'), $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin user: can access everything
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('adminAllowedRoutesProvider')]
|
||||||
|
public function testAdminCanAccessAllRoutes(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->loginAs('admin');
|
||||||
|
$client->request('GET', $url);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
}
|
||||||
247
tests/Controller/BrowserPluginControllerTest.php
Normal file
247
tests/Controller/BrowserPluginControllerTest.php
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
tests/Controller/SelectApiControllerTest.php
Normal file
152
tests/Controller/SelectApiControllerTest.php
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the SelectAPIController endpoints used by select2 widgets.
|
||||||
|
* These JSON endpoints back every structural-entity dropdown in the UI.
|
||||||
|
*/
|
||||||
|
#[Group('DB')]
|
||||||
|
#[Group('slow')]
|
||||||
|
final class SelectApiControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public static function endpointProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'category' => ['/en/select_api/category'];
|
||||||
|
yield 'footprint' => ['/en/select_api/footprint'];
|
||||||
|
yield 'manufacturer' => ['/en/select_api/manufacturer'];
|
||||||
|
yield 'measurement_unit' => ['/en/select_api/measurement_unit'];
|
||||||
|
yield 'project' => ['/en/select_api/project'];
|
||||||
|
yield 'storage_location' => ['/en/select_api/storage_location'];
|
||||||
|
yield 'label_profiles' => ['/en/select_api/label_profiles'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function adminClient(): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$admin = $em->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
||||||
|
if ($admin === null) {
|
||||||
|
$this->markTestSkipped('Fixture user admin not found.');
|
||||||
|
}
|
||||||
|
$client->loginUser($admin);
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Response format
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testEndpointReturns200WithJsonContentType(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->adminClient();
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertResponseHeaderSame('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testEndpointReturnsValidJsonArray(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->adminClient();
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$body = $client->getResponse()->getContent();
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
|
$this->assertIsArray($decoded, "Response from $url is not a valid JSON array");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testEachEntryHasTextAndValueKeys(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->adminClient();
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$decoded = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
// Some endpoints include an empty "select none" entry at index 0; all entries must have text + value
|
||||||
|
foreach ($decoded as $entry) {
|
||||||
|
$this->assertArrayHasKey('text', $entry, "Entry in $url missing 'text' key");
|
||||||
|
$this->assertArrayHasKey('value', $entry, "Entry in $url missing 'value' key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Access control
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testUnauthenticatedCanReadSelectApi(string $url): void
|
||||||
|
{
|
||||||
|
// The anonymous user (readonly group) has read access to structural entities,
|
||||||
|
// so these endpoints return 200 even without a session.
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', $url);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testNoreadUserIsDenied(string $url): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$noread = $em->getRepository(User::class)->findOneBy(['name' => 'noread']);
|
||||||
|
if ($noread === null) {
|
||||||
|
$this->markTestSkipped('Fixture user noread not found.');
|
||||||
|
}
|
||||||
|
$client->loginUser($noread);
|
||||||
|
$client->followRedirects(false);
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
$this->assertTrue(
|
||||||
|
$response->getStatusCode() === 403 || $response->isRedirect(),
|
||||||
|
"Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testEditorUserCanAccess(string $url): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$user = $em->getRepository(User::class)->findOneBy(['name' => 'user']);
|
||||||
|
if ($user === null) {
|
||||||
|
$this->markTestSkipped('Fixture user user not found.');
|
||||||
|
}
|
||||||
|
$client->loginUser($user);
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
}
|
||||||
162
tests/Controller/TypeaheadControllerTest.php
Normal file
162
tests/Controller/TypeaheadControllerTest.php
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the TypeaheadController JSON endpoints that back autocomplete widgets in the UI.
|
||||||
|
*/
|
||||||
|
#[Group('DB')]
|
||||||
|
#[Group('slow')]
|
||||||
|
final class TypeaheadControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public static function endpointProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'tags search' => ['/en/typeahead/tags/search/test'];
|
||||||
|
yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage'];
|
||||||
|
yield 'parameters category search' => ['/en/typeahead/parameters/category/search/NPN'];
|
||||||
|
yield 'builtin resources' => ['/en/typeahead/builtInResources/search?query=DIP'];
|
||||||
|
yield 'parts search' => ['/en/typeahead/parts/search/res'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function partsReadEndpointProvider(): \Generator
|
||||||
|
{
|
||||||
|
// These require @parts.read — noread user must be denied
|
||||||
|
yield 'tags search' => ['/en/typeahead/tags/search/test'];
|
||||||
|
yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage'];
|
||||||
|
yield 'parts search' => ['/en/typeahead/parts/search/res'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginClient(string $username): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$user = $em->getRepository(User::class)->findOneBy(['name' => $username]);
|
||||||
|
if ($user === null) {
|
||||||
|
$this->markTestSkipped("Fixture user '$username' not found.");
|
||||||
|
}
|
||||||
|
$client->loginUser($user);
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Response format
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testEndpointReturnsSuccessfulJsonForAdmin(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->loginClient('admin');
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertJson($client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testEndpointReturnsJsonArray(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->loginClient('admin');
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$decoded = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertIsArray($decoded, "Response from $url should be a JSON array");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Tags search: result structure
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testTagsSearchReturnsStrings(): void
|
||||||
|
{
|
||||||
|
$client = $this->loginClient('admin');
|
||||||
|
$client->request('GET', '/en/typeahead/tags/search/a');
|
||||||
|
|
||||||
|
$tags = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertIsArray($tags);
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$this->assertIsString($tag, 'Each tag entry should be a plain string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Parts search: result structure
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testPartsSearchReturnsArrayWithExpectedKeys(): void
|
||||||
|
{
|
||||||
|
$client = $this->loginClient('admin');
|
||||||
|
$client->request('GET', '/en/typeahead/parts/search/test');
|
||||||
|
|
||||||
|
$parts = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertIsArray($parts);
|
||||||
|
// Each result must have at least id and name
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$this->assertArrayHasKey('id', $part);
|
||||||
|
$this->assertArrayHasKey('name', $part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Access control
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testUnauthenticatedCanAccessTypeahead(string $url): void
|
||||||
|
{
|
||||||
|
// Anonymous user (readonly group) has @parts.read, so these endpoints return 200.
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', $url);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('partsReadEndpointProvider')]
|
||||||
|
public function testNoreadUserIsDenied(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->loginClient('noread');
|
||||||
|
$client->followRedirects(false);
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
$this->assertTrue(
|
||||||
|
$response->getStatusCode() === 403 || $response->isRedirect(),
|
||||||
|
"Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('endpointProvider')]
|
||||||
|
public function testEditorUserCanAccess(string $url): void
|
||||||
|
{
|
||||||
|
$client = $this->loginClient('user');
|
||||||
|
$client->request('GET', $url);
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
tests/EventSubscriber/MaintenanceModeSubscriberTest.php
Normal file
103
tests/EventSubscriber/MaintenanceModeSubscriberTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\EventSubscriber;
|
||||||
|
|
||||||
|
use App\EventSubscriber\MaintenanceModeSubscriber;
|
||||||
|
use App\Services\System\UpdateExecutor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
|
||||||
|
final class MaintenanceModeSubscriberTest extends TestCase
|
||||||
|
{
|
||||||
|
private function makeSubscriber(bool $maintenanceActive): MaintenanceModeSubscriber
|
||||||
|
{
|
||||||
|
$executor = $this->createMock(UpdateExecutor::class);
|
||||||
|
$executor->method('isMaintenanceMode')->willReturn($maintenanceActive);
|
||||||
|
$executor->method('getMaintenanceInfo')->willReturn(
|
||||||
|
$maintenanceActive ? ['reason' => 'Test update', 'enabled_at' => date('Y-m-d H:i:s')] : null
|
||||||
|
);
|
||||||
|
return new MaintenanceModeSubscriber($executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(string $url = 'http://example.com/'): RequestEvent
|
||||||
|
{
|
||||||
|
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||||
|
$request = Request::create($url);
|
||||||
|
return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoMaintenanceModeDoesNotSetResponse(): void
|
||||||
|
{
|
||||||
|
$subscriber = $this->makeSubscriber(false);
|
||||||
|
$event = $this->makeEvent();
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
// When not in maintenance mode, no response is ever set regardless of SAPI
|
||||||
|
$this->assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCliRequestIsNeverBlocked(): void
|
||||||
|
{
|
||||||
|
// Tests run from CLI (PHP_SAPI === 'cli'), so maintenance mode never blocks CLI requests.
|
||||||
|
// This verifies the intentional behaviour: maintenance mode only affects web requests.
|
||||||
|
$subscriber = $this->makeSubscriber(true);
|
||||||
|
$event = $this->makeEvent();
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
// CLI requests pass through even with maintenance active
|
||||||
|
$this->assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubRequestIsIgnored(): void
|
||||||
|
{
|
||||||
|
$subscriber = $this->makeSubscriber(true);
|
||||||
|
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||||
|
$request = Request::create('http://example.com/');
|
||||||
|
$event = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST);
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
$this->assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubscriberListensToKernelRequest(): void
|
||||||
|
{
|
||||||
|
$events = MaintenanceModeSubscriber::getSubscribedEvents();
|
||||||
|
$this->assertArrayHasKey(KernelEvents::REQUEST, $events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubscriberListensWithHighPriority(): void
|
||||||
|
{
|
||||||
|
$events = MaintenanceModeSubscriber::getSubscribedEvents();
|
||||||
|
$config = $events[KernelEvents::REQUEST];
|
||||||
|
// Config is ['methodName', priority]
|
||||||
|
$priority = is_array($config) ? (int) ($config[1] ?? 0) : 0;
|
||||||
|
$this->assertGreaterThan(0, $priority, 'Maintenance subscriber should run with high priority');
|
||||||
|
}
|
||||||
|
}
|
||||||
101
tests/EventSubscriber/RedirectToHttpsSubscriberTest.php
Normal file
101
tests/EventSubscriber/RedirectToHttpsSubscriberTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\EventSubscriber;
|
||||||
|
|
||||||
|
use App\EventSubscriber\RedirectToHttpsSubscriber;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
use Symfony\Component\Security\Http\HttpUtils;
|
||||||
|
|
||||||
|
final class RedirectToHttpsSubscriberTest extends TestCase
|
||||||
|
{
|
||||||
|
private function makeEvent(string $url, bool $isMainRequest = true): RequestEvent
|
||||||
|
{
|
||||||
|
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||||
|
$request = Request::create($url);
|
||||||
|
return new RequestEvent($kernel, $request, $isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHttpRequestIsRedirectedToHttpsWhenEnabled(): void
|
||||||
|
{
|
||||||
|
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
|
||||||
|
$event = $this->makeEvent('http://example.com/some/path');
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
$this->assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
$this->assertStringStartsWith('https://', $response->getTargetUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHttpsRequestIsNotRedirectedWhenEnabled(): void
|
||||||
|
{
|
||||||
|
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
|
||||||
|
$event = $this->makeEvent('https://example.com/some/path');
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
$this->assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHttpRequestIsNotRedirectedWhenDisabled(): void
|
||||||
|
{
|
||||||
|
$subscriber = new RedirectToHttpsSubscriber(false, new HttpUtils());
|
||||||
|
$event = $this->makeEvent('http://example.com/some/path');
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
$this->assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubRequestIsNotRedirected(): void
|
||||||
|
{
|
||||||
|
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
|
||||||
|
$event = $this->makeEvent('http://example.com/', false);
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
$this->assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedirectUrlPreservesPath(): void
|
||||||
|
{
|
||||||
|
$subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils());
|
||||||
|
$event = $this->makeEvent('http://example.com/admin/parts?q=test');
|
||||||
|
|
||||||
|
$subscriber->onKernelRequest($event);
|
||||||
|
|
||||||
|
$this->assertTrue($event->hasResponse());
|
||||||
|
$this->assertStringContainsString('/admin/parts', $event->getResponse()->getTargetUrl());
|
||||||
|
$this->assertStringContainsString('q=test', $event->getResponse()->getTargetUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubscriberListensToKernelRequestEvent(): void
|
||||||
|
{
|
||||||
|
$events = RedirectToHttpsSubscriber::getSubscribedEvents();
|
||||||
|
$this->assertArrayHasKey('kernel.request', $events);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
tests/Services/Cache/ElementCacheTagGeneratorTest.php
Normal file
67
tests/Services/Cache/ElementCacheTagGeneratorTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\Cache;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Services\Cache\ElementCacheTagGenerator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ElementCacheTagGeneratorTest extends TestCase
|
||||||
|
{
|
||||||
|
private ElementCacheTagGenerator $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->service = new ElementCacheTagGenerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClassNameIsConvertedToTag(): void
|
||||||
|
{
|
||||||
|
$tag = $this->service->getElementTypeCacheTag(Part::class);
|
||||||
|
// Backslashes must be replaced by underscores
|
||||||
|
$this->assertStringNotContainsString('\\', $tag);
|
||||||
|
$this->assertSame(str_replace('\\', '_', Part::class), $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testObjectInputGivesSameResultAsClassName(): void
|
||||||
|
{
|
||||||
|
$part = new Part();
|
||||||
|
$tagFromObject = $this->service->getElementTypeCacheTag($part);
|
||||||
|
$tagFromClass = $this->service->getElementTypeCacheTag(Part::class);
|
||||||
|
$this->assertSame($tagFromClass, $tagFromObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResultIsCached(): void
|
||||||
|
{
|
||||||
|
$tag1 = $this->service->getElementTypeCacheTag(Part::class);
|
||||||
|
$tag2 = $this->service->getElementTypeCacheTag(Part::class);
|
||||||
|
$this->assertSame($tag1, $tag2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonExistentClassThrowsException(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->getElementTypeCacheTag('App\\NonExistent\\Foo');
|
||||||
|
}
|
||||||
|
}
|
||||||
110
tests/Services/Cache/UserCacheKeyGeneratorTest.php
Normal file
110
tests/Services/Cache/UserCacheKeyGeneratorTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\Cache;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use App\Services\Cache\UserCacheKeyGenerator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
final class UserCacheKeyGeneratorTest extends TestCase
|
||||||
|
{
|
||||||
|
private function makeGenerator(?User $loggedInUser, ?Request $request = null): UserCacheKeyGenerator
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn($loggedInUser);
|
||||||
|
|
||||||
|
$requestStack = $this->createMock(RequestStack::class);
|
||||||
|
$requestStack->method('getCurrentRequest')->willReturn($request);
|
||||||
|
|
||||||
|
return new UserCacheKeyGenerator($security, $requestStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUserWithId(int $id): User
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$ref = new \ReflectionProperty(User::class, 'id');
|
||||||
|
$ref->setValue($user, $id);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnonymousUserKeyContainsAnonymousId(): void
|
||||||
|
{
|
||||||
|
$service = $this->makeGenerator(null);
|
||||||
|
$key = $service->generateKey();
|
||||||
|
$this->assertStringContainsString((string) User::ID_ANONYMOUS, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExplicitAnonymousUserGivesSameKeyAsNull(): void
|
||||||
|
{
|
||||||
|
$anonUser = $this->makeUserWithId(User::ID_ANONYMOUS);
|
||||||
|
$anonUser->setName('anonymous');
|
||||||
|
|
||||||
|
$service = $this->makeGenerator(null);
|
||||||
|
$keyFromNull = $service->generateKey(null);
|
||||||
|
$keyFromAnon = $service->generateKey($anonUser);
|
||||||
|
$this->assertSame($keyFromNull, $keyFromAnon);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeyForRealUserContainsUserId(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUserWithId(42);
|
||||||
|
$service = $this->makeGenerator(null);
|
||||||
|
|
||||||
|
$key = $service->generateKey($user);
|
||||||
|
$this->assertStringContainsString('42', $key);
|
||||||
|
$this->assertStringNotContainsString((string) User::ID_ANONYMOUS, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLocaleFromRequestIsIncludedInKey(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/');
|
||||||
|
$request->setLocale('de');
|
||||||
|
|
||||||
|
$service = $this->makeGenerator(null, $request);
|
||||||
|
$key = $service->generateKey();
|
||||||
|
$this->assertStringContainsString('de', $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDifferentUsersProduceDifferentKeys(): void
|
||||||
|
{
|
||||||
|
$service = $this->makeGenerator(null);
|
||||||
|
|
||||||
|
$user1 = $this->makeUserWithId(10);
|
||||||
|
$user2 = $this->makeUserWithId(20);
|
||||||
|
|
||||||
|
$this->assertNotSame($service->generateKey($user1), $service->generateKey($user2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCurrentlyLoggedInUserIsUsedWhenNoExplicitUser(): void
|
||||||
|
{
|
||||||
|
$loggedIn = $this->makeUserWithId(99);
|
||||||
|
$service = $this->makeGenerator($loggedIn);
|
||||||
|
|
||||||
|
$key = $service->generateKey();
|
||||||
|
$this->assertStringContainsString('99', $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
tests/Services/EntityURLGeneratorTest.php
Normal file
113
tests/Services/EntityURLGeneratorTest.php
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services;
|
||||||
|
|
||||||
|
use App\Entity\Base\AbstractDBElement;
|
||||||
|
use App\Entity\Parts\Category;
|
||||||
|
use App\Entity\Parts\Footprint;
|
||||||
|
use App\Entity\Parts\Manufacturer;
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\Parts\StorageLocation;
|
||||||
|
use App\Entity\Parts\Supplier;
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use App\Exceptions\EntityNotSupportedException;
|
||||||
|
use App\Services\EntityURLGenerator;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
final class EntityURLGeneratorTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static EntityURLGenerator $service;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
self::$service = self::getContainer()->get(EntityURLGenerator::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityWithId(string $class, int $id): AbstractDBElement
|
||||||
|
{
|
||||||
|
$entity = new $class();
|
||||||
|
$ref = new \ReflectionProperty(AbstractDBElement::class, 'id');
|
||||||
|
$ref->setValue($entity, $id);
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInfoUrlForPartContainsPartPath(): void
|
||||||
|
{
|
||||||
|
$part = $this->entityWithId(Part::class, 1);
|
||||||
|
$url = self::$service->infoURL($part);
|
||||||
|
$this->assertStringContainsString('part', $url);
|
||||||
|
$this->assertStringContainsString('1', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditUrlForCategoryContainsCategoryPath(): void
|
||||||
|
{
|
||||||
|
$category = $this->entityWithId(Category::class, 5);
|
||||||
|
$url = self::$service->editURL($category);
|
||||||
|
$this->assertStringContainsString('category', $url);
|
||||||
|
$this->assertStringContainsString('5', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListPartsUrlForSupplierContainsSupplierPath(): void
|
||||||
|
{
|
||||||
|
$supplier = $this->entityWithId(Supplier::class, 7);
|
||||||
|
$url = self::$service->listPartsURL($supplier);
|
||||||
|
$this->assertStringContainsString('supplier', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetUrlWithInfoTypeCallsInfoUrl(): void
|
||||||
|
{
|
||||||
|
$part = $this->entityWithId(Part::class, 3);
|
||||||
|
$url = self::$service->getURL($part, 'info');
|
||||||
|
$this->assertStringContainsString('part', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetUrlWithEditTypeCallsEditUrl(): void
|
||||||
|
{
|
||||||
|
$manufacturer = $this->entityWithId(Manufacturer::class, 2);
|
||||||
|
$url = self::$service->getURL($manufacturer, 'edit');
|
||||||
|
$this->assertStringContainsString('manufacturer', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetUrlWithUnknownTypeThrowsException(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$part = $this->entityWithId(Part::class, 1);
|
||||||
|
self::$service->getURL($part, 'unsupported_type');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInfoUrlForUserContainsUserPath(): void
|
||||||
|
{
|
||||||
|
$user = $this->entityWithId(User::class, 10);
|
||||||
|
$url = self::$service->editURL($user);
|
||||||
|
$this->assertStringContainsString('user', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListPartsUrlForStorelocationContainsStorelocationPath(): void
|
||||||
|
{
|
||||||
|
$loc = $this->entityWithId(StorageLocation::class, 4);
|
||||||
|
$url = self::$service->listPartsURL($loc);
|
||||||
|
$this->assertStringContainsString('store', $url);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/Services/Formatters/MarkdownParserTest.php
Normal file
86
tests/Services/Formatters/MarkdownParserTest.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\Formatters;
|
||||||
|
|
||||||
|
use App\Services\Formatters\MarkdownParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
final class MarkdownParserTest extends TestCase
|
||||||
|
{
|
||||||
|
private MarkdownParser $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$translator = $this->createMock(TranslatorInterface::class);
|
||||||
|
$translator->method('trans')->willReturn('Loading...');
|
||||||
|
$this->service = new MarkdownParser($translator);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOutputContainsDataMarkdownAttribute(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->markForRendering('**hello**');
|
||||||
|
$this->assertStringContainsString('data-markdown=', $result);
|
||||||
|
$this->assertStringContainsString('data-controller="common--markdown"', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarkdownContentIsHtmlescapedInAttribute(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->markForRendering('<script>alert(1)</script>');
|
||||||
|
// The raw < should not appear unescaped inside the attribute
|
||||||
|
$this->assertStringNotContainsString('<script>', $result);
|
||||||
|
$this->assertStringContainsString('<script>', $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');
|
||||||
|
}
|
||||||
|
}
|
||||||
103
tests/Services/Formatters/MoneyFormatterTest.php
Normal file
103
tests/Services/Formatters/MoneyFormatterTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\Formatters;
|
||||||
|
|
||||||
|
use App\Entity\PriceInformations\Currency;
|
||||||
|
use App\Services\Formatters\MoneyFormatter;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
final class MoneyFormatterTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static MoneyFormatter $service;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
self::$service = self::getContainer()->get(MoneyFormatter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatWithFloatInput(): void
|
||||||
|
{
|
||||||
|
$currency = new Currency();
|
||||||
|
$currency->setIsoCode('USD');
|
||||||
|
$result = self::$service->format(1.5, $currency);
|
||||||
|
|
||||||
|
// Output format varies by locale, so verify content not exact form
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$this->assertStringContainsString('1', $result);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($result, '$') || str_contains($result, 'USD'),
|
||||||
|
"Expected USD indicator in: $result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatWithNullCurrencyUsesBaseCurrency(): void
|
||||||
|
{
|
||||||
|
$result = self::$service->format(1.5);
|
||||||
|
// Should return a non-empty formatted string
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$this->assertIsString($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatWithExplicitCurrencyUsesThatCurrency(): void
|
||||||
|
{
|
||||||
|
$currency = new Currency();
|
||||||
|
$currency->setIsoCode('USD');
|
||||||
|
|
||||||
|
$result = self::$service->format(10.0, $currency);
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$this->assertStringContainsString('10', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatStringInputWorksSameAsFloat(): void
|
||||||
|
{
|
||||||
|
$resultFloat = self::$service->format(1.5);
|
||||||
|
$resultString = self::$service->format('1.5');
|
||||||
|
$this->assertSame($resultFloat, $resultString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowAllDigitsRespectsFractionCount(): void
|
||||||
|
{
|
||||||
|
// With show_all_digits = true and decimals = 3, we expect exactly 3 decimal places
|
||||||
|
$result = self::$service->format(1.5, null, 3, true);
|
||||||
|
// The number should contain exactly 3 decimal digits
|
||||||
|
$this->assertMatchesRegularExpression('/\d{3}(?!\d)/', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZeroIsFormattedCorrectly(): void
|
||||||
|
{
|
||||||
|
$result = self::$service->format(0.0);
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$this->assertStringContainsString('0', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCurrencyWithEmptyIsoCodeFallsBackToBaseCurrency(): void
|
||||||
|
{
|
||||||
|
$currency = new Currency();
|
||||||
|
// Empty ISO code → should fall back to base currency
|
||||||
|
$resultWithEmpty = self::$service->format(1.0, $currency);
|
||||||
|
$resultWithNull = self::$service->format(1.0, null);
|
||||||
|
$this->assertSame($resultWithNull, $resultWithEmpty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
tests/Services/InfoProviderSystem/SubmittedPageStorageTest.php
Normal file
181
tests/Services/InfoProviderSystem/SubmittedPageStorageTest.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,4 +87,32 @@ final class EventCommentHelperTest extends WebTestCase
|
||||||
$this->service->clearMessage();
|
$this->service->clearMessage();
|
||||||
$this->assertFalse($this->service->isMessageSet());
|
$this->assertFalse($this->service->isMessageSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testEmptyStringTreatedAsNotSet(): void
|
||||||
|
{
|
||||||
|
// Empty string is falsy in PHP, so setMessage('') stores null internally
|
||||||
|
$this->service->setMessage('');
|
||||||
|
$this->assertFalse($this->service->isMessageSet());
|
||||||
|
$this->assertNull($this->service->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetMessageNullClearsMessage(): void
|
||||||
|
{
|
||||||
|
$this->service->setMessage('Hello');
|
||||||
|
$this->service->setMessage(null);
|
||||||
|
$this->assertFalse($this->service->isMessageSet());
|
||||||
|
$this->assertNull($this->service->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLongMessageIsTruncated(): void
|
||||||
|
{
|
||||||
|
// MAX_MESSAGE_LENGTH is 255; a longer string should be truncated with '...' suffix
|
||||||
|
$long = str_repeat('a', 300);
|
||||||
|
$this->service->setMessage($long);
|
||||||
|
|
||||||
|
$stored = $this->service->getMessage();
|
||||||
|
$this->assertNotNull($stored);
|
||||||
|
$this->assertLessThanOrEqual(255, mb_strlen($stored));
|
||||||
|
$this->assertStringEndsWith('...', $stored);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
tests/Services/LogSystem/LogDataFormatterTest.php
Normal file
133
tests/Services/LogSystem/LogDataFormatterTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\LogSystem;
|
||||||
|
|
||||||
|
use App\Entity\LogSystem\AbstractLogEntry;
|
||||||
|
use App\Services\LogSystem\LogDataFormatter;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
final class LogDataFormatterTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static LogDataFormatter $service;
|
||||||
|
private static AbstractLogEntry $dummyLog;
|
||||||
|
private AbstractLogEntry $dummy;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
self::$service = self::getContainer()->get(LogDataFormatter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
// A mock is fine: $logEntry is only consulted for @id (foreign key) arrays
|
||||||
|
$this->dummy = $this->createMock(AbstractLogEntry::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStringIsWrappedInQuoteSpans(): void
|
||||||
|
{
|
||||||
|
$result = self::$service->formatData('hello', $this->dummy, 'name');
|
||||||
|
$this->assertStringContainsString('"', $result);
|
||||||
|
$this->assertStringContainsString('hello', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStringSpecialCharsAreEscaped(): void
|
||||||
|
{
|
||||||
|
$result = self::$service->formatData('<script>', $this->dummy, 'name');
|
||||||
|
$this->assertStringNotContainsString('<script>', $result);
|
||||||
|
$this->assertStringContainsString('<script>', $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');
|
||||||
|
}
|
||||||
|
}
|
||||||
79
tests/Services/LogSystem/LogDiffFormatterTest.php
Normal file
79
tests/Services/LogSystem/LogDiffFormatterTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\LogSystem;
|
||||||
|
|
||||||
|
use App\Services\LogSystem\LogDiffFormatter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class LogDiffFormatterTest extends TestCase
|
||||||
|
{
|
||||||
|
private LogDiffFormatter $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->service = new LogDiffFormatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPositiveNumericDiff(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->formatDiff(1, 6);
|
||||||
|
$this->assertStringContainsString('text-success', $result);
|
||||||
|
$this->assertStringContainsString('+5', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNegativeNumericDiff(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->formatDiff(10, 3);
|
||||||
|
$this->assertStringContainsString('text-danger', $result);
|
||||||
|
$this->assertStringContainsString('-7', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZeroNumericDiff(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->formatDiff(5, 5);
|
||||||
|
$this->assertStringContainsString('text-muted', $result);
|
||||||
|
$this->assertStringContainsString('0', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStringDiffReturnsNonEmptyHtml(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->formatDiff('hello world', 'hello PHP');
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
// DiffHelper returns HTML
|
||||||
|
$this->assertStringContainsString('<', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnsupportedTypesReturnEmptyString(): void
|
||||||
|
{
|
||||||
|
// booleans are neither string nor numeric → empty
|
||||||
|
$result = $this->service->formatDiff(true, false);
|
||||||
|
$this->assertSame('', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFloatDiff(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->formatDiff(1.5, 3.0);
|
||||||
|
$this->assertStringContainsString('text-success', $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
tests/Services/LogSystem/LogEntryExtraFormatterTest.php
Normal file
92
tests/Services/LogSystem/LogEntryExtraFormatterTest.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\LogSystem;
|
||||||
|
|
||||||
|
use App\Entity\LogSystem\DatabaseUpdatedLogEntry;
|
||||||
|
use App\Entity\LogSystem\UserLoginLogEntry;
|
||||||
|
use App\Entity\LogSystem\UserLogoutLogEntry;
|
||||||
|
use App\Services\LogSystem\LogEntryExtraFormatter;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
final class LogEntryExtraFormatterTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static LogEntryExtraFormatter $service;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
self::$service = self::getContainer()->get(LogEntryExtraFormatter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatUserLoginLogEntryContainsIp(): void
|
||||||
|
{
|
||||||
|
$entry = new UserLoginLogEntry('127.0.0.1', anonymize: false);
|
||||||
|
$result = self::$service->format($entry);
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$this->assertStringContainsString('127.0.0.1', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatDatabaseUpdatedLogEntryContainsVersions(): void
|
||||||
|
{
|
||||||
|
$entry = new DatabaseUpdatedLogEntry('1.0.0', '2.0.0');
|
||||||
|
$result = self::$service->format($entry);
|
||||||
|
$this->assertStringContainsString('1.0.0', $result);
|
||||||
|
$this->assertStringContainsString('2.0.0', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatUserLogoutContainsIp(): void
|
||||||
|
{
|
||||||
|
$entry = new UserLogoutLogEntry('10.0.0.1', anonymize: false);
|
||||||
|
$result = self::$service->format($entry);
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$this->assertStringContainsString('10.0.0.1', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatConsoleReplacesHtmlTags(): void
|
||||||
|
{
|
||||||
|
$entry = new DatabaseUpdatedLogEntry('1.0', '2.0');
|
||||||
|
$result = self::$service->formatConsole($entry);
|
||||||
|
// Console format replaces the arrow icon with →
|
||||||
|
$this->assertStringContainsString('→', $result);
|
||||||
|
// No raw HTML tags should remain from the arrow icon
|
||||||
|
$this->assertStringNotContainsString('<i class="fas fa-long-arrow-alt-right"></i>', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormatConsoleReturnsString(): void
|
||||||
|
{
|
||||||
|
$entry = new UserLoginLogEntry('192.168.1.1', anonymize: false);
|
||||||
|
$result = self::$service->formatConsole($entry);
|
||||||
|
$this->assertIsString($result);
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIpAddressIsHtmlEscapedInFormat(): void
|
||||||
|
{
|
||||||
|
// Verify that the IP embedded in the result is safe (htmlspecialchars is applied)
|
||||||
|
$entry = new UserLoginLogEntry('192.168.0.1', anonymize: false);
|
||||||
|
$result = self::$service->format($entry);
|
||||||
|
// The result must not contain unescaped HTML even from a crafted IP
|
||||||
|
$this->assertStringNotContainsString('<script>', $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/Services/LogSystem/LogLevelHelperTest.php
Normal file
85
tests/Services/LogSystem/LogLevelHelperTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\LogSystem;
|
||||||
|
|
||||||
|
use App\Services\LogSystem\LogLevelHelper;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LogLevel;
|
||||||
|
|
||||||
|
final class LogLevelHelperTest extends TestCase
|
||||||
|
{
|
||||||
|
private LogLevelHelper $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->service = new LogLevelHelper();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function iconClassProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield [LogLevel::DEBUG, 'fa-bug'];
|
||||||
|
yield [LogLevel::INFO, 'fa-info'];
|
||||||
|
yield [LogLevel::NOTICE, 'fa-flag'];
|
||||||
|
yield [LogLevel::WARNING, 'fa-exclamation-circle'];
|
||||||
|
yield [LogLevel::ERROR, 'fa-exclamation-triangle'];
|
||||||
|
yield [LogLevel::CRITICAL, 'fa-bolt'];
|
||||||
|
yield [LogLevel::ALERT, 'fa-radiation'];
|
||||||
|
yield [LogLevel::EMERGENCY, 'fa-skull-crossbones'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('iconClassProvider')]
|
||||||
|
public function testLogLevelToIconClass(string $logLevel, string $expectedIcon): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expectedIcon, $this->service->logLevelToIconClass($logLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownLogLevelReturnsDefaultIcon(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('fa-question-circle', $this->service->logLevelToIconClass('unknown_level'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tableColorProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield [LogLevel::EMERGENCY, 'table-danger'];
|
||||||
|
yield [LogLevel::ALERT, 'table-danger'];
|
||||||
|
yield [LogLevel::CRITICAL, 'table-danger'];
|
||||||
|
yield [LogLevel::ERROR, 'table-danger'];
|
||||||
|
yield [LogLevel::WARNING, 'table-warning'];
|
||||||
|
yield [LogLevel::NOTICE, 'table-info'];
|
||||||
|
yield [LogLevel::INFO, ''];
|
||||||
|
yield [LogLevel::DEBUG, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('tableColorProvider')]
|
||||||
|
public function testLogLevelToTableColorClass(string $logLevel, string $expectedClass): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expectedClass, $this->service->logLevelToTableColorClass($logLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownLogLevelReturnsEmptyColor(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('', $this->service->logLevelToTableColorClass('unknown_level'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Tests\Services\Parts;
|
namespace App\Tests\Services\Parts;
|
||||||
|
|
||||||
|
use App\Entity\Parts\MeasurementUnit;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\Parts\PartLot;
|
use App\Entity\Parts\PartLot;
|
||||||
use App\Entity\Parts\StorageLocation;
|
use App\Entity\Parts\StorageLocation;
|
||||||
|
|
@ -167,6 +168,223 @@ final class PartLotWithdrawAddHelperTest extends WebTestCase
|
||||||
$this->service->stocktake($this->partLot2, 0, "Test");
|
$this->service->stocktake($this->partLot2, 0, "Test");
|
||||||
$this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON);
|
$this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
$this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared
|
$this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- withdraw() error paths ---
|
||||||
|
|
||||||
|
public function testWithdrawZeroAmountThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->withdraw($this->partLot1, 0, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawNegativeAmountThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->withdraw($this->partLot1, -5, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawMoreThanStockThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->withdraw($this->partLot1, 999, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawFromUnknownInstockLotThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->withdraw($this->lotWithUnknownInstock, 1, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- add() error paths ---
|
||||||
|
|
||||||
|
public function testAddZeroAmountThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->add($this->partLot1, 0, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddNegativeAmountThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->add($this->partLot1, -3, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddToFullLotThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->add($this->fullLot, 1, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddToUnknownInstockLotThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->add($this->lotWithUnknownInstock, 1, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- move() error paths ---
|
||||||
|
|
||||||
|
public function testMoveZeroAmountThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->move($this->partLot1, $this->partLot2, 0, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveBetweenDifferentPartsThrows(): void
|
||||||
|
{
|
||||||
|
$otherPart = new Part();
|
||||||
|
$otherLot = new TestPartLot();
|
||||||
|
$otherLot->setPart($otherPart);
|
||||||
|
$otherLot->setAmount(5);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->move($this->partLot1, $otherLot, 5, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveMoreThanOriginStockThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->move($this->partLot1, $this->partLot2, 999, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveFromUnwithdrawableLotThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->move($this->lotWithUnknownInstock, $this->partLot2, 1, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveToUnavailableLotThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->move($this->partLot1, $this->fullLot, 1, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stocktake() error paths ---
|
||||||
|
|
||||||
|
public function testStocktakeNegativeAmountThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->stocktake($this->partLot1, -1, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- integer-rounding (useFloatAmount() = false, no unit set) ---
|
||||||
|
|
||||||
|
public function testWithdrawRoundsAmountForIntegerPart(): void
|
||||||
|
{
|
||||||
|
// No unit → useFloatAmount() = false → fractional amounts are rounded
|
||||||
|
$this->assertFalse($this->part->useFloatAmount());
|
||||||
|
|
||||||
|
$this->service->withdraw($this->partLot1, 1.7, "Test"); // rounds to 2
|
||||||
|
$this->assertEqualsWithDelta(8.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddRoundsAmountForIntegerPart(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->part->useFloatAmount());
|
||||||
|
|
||||||
|
$this->service->add($this->partLot3, 1.7, "Test"); // rounds to 2
|
||||||
|
$this->assertEqualsWithDelta(2.0, $this->partLot3->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStocktakeRoundsAmountForIntegerPart(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->part->useFloatAmount());
|
||||||
|
|
||||||
|
$this->service->stocktake($this->partLot1, 7.6, "Test"); // rounds to 8
|
||||||
|
$this->assertEqualsWithDelta(8.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- float amounts are preserved when the unit allows floats ---
|
||||||
|
|
||||||
|
public function testAddPreservesFloatAmountForFloatUnit(): void
|
||||||
|
{
|
||||||
|
$unit = new MeasurementUnit();
|
||||||
|
$unit->setIsInteger(false);
|
||||||
|
|
||||||
|
$floatPart = new Part();
|
||||||
|
$floatPart->setPartUnit($unit);
|
||||||
|
$this->assertTrue($floatPart->useFloatAmount());
|
||||||
|
|
||||||
|
$lot = new TestPartLot();
|
||||||
|
$lot->setPart($floatPart);
|
||||||
|
$lot->setAmount(1.0);
|
||||||
|
|
||||||
|
$this->service->add($lot, 1.3, "Test");
|
||||||
|
$this->assertEqualsWithDelta(2.3, $lot->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawPreservesFloatAmountForFloatUnit(): void
|
||||||
|
{
|
||||||
|
$unit = new MeasurementUnit();
|
||||||
|
$unit->setIsInteger(false);
|
||||||
|
|
||||||
|
$floatPart = new Part();
|
||||||
|
$floatPart->setPartUnit($unit);
|
||||||
|
|
||||||
|
$lot = new TestPartLot();
|
||||||
|
$lot->setPart($floatPart);
|
||||||
|
$lot->setAmount(5.0);
|
||||||
|
|
||||||
|
$this->service->withdraw($lot, 1.3, "Test");
|
||||||
|
$this->assertEqualsWithDelta(3.7, $lot->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete_lot_if_empty ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a PartLot that looks like a managed, persisted entity to Doctrine:
|
||||||
|
* - has a non-null ID (required by AbstractLogEntry when creating stock-change log entries)
|
||||||
|
* - is registered in the UnitOfWork as managed (required so EntityManager::remove() accepts it)
|
||||||
|
*/
|
||||||
|
private function makeManagedLot(float $amount, int $fakeId = 42): PartLot
|
||||||
|
{
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setPart($this->part);
|
||||||
|
$lot->setAmount($amount);
|
||||||
|
|
||||||
|
$ref = new \ReflectionProperty($lot, 'id');
|
||||||
|
$ref->setValue($lot, $fakeId);
|
||||||
|
|
||||||
|
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||||
|
$em->getUnitOfWork()->registerManaged($lot, ['id' => $fakeId], []);
|
||||||
|
|
||||||
|
return $lot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawDeletesLotWhenEmptyAndFlagSet(): void
|
||||||
|
{
|
||||||
|
$lot = $this->makeManagedLot(10);
|
||||||
|
|
||||||
|
$this->service->withdraw($lot, 10, "Test", null, true);
|
||||||
|
$this->assertEqualsWithDelta(0.0, $lot->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
|
||||||
|
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||||
|
$scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions();
|
||||||
|
$this->assertContains($lot, $scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithdrawDoesNotDeleteLotWhenNotEmptyAndFlagSet(): void
|
||||||
|
{
|
||||||
|
$lot = $this->makeManagedLot(10);
|
||||||
|
|
||||||
|
$this->service->withdraw($lot, 5, "Test", null, true);
|
||||||
|
$this->assertEqualsWithDelta(5.0, $lot->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
|
||||||
|
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||||
|
$scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions();
|
||||||
|
$this->assertNotContains($lot, $scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveDeletesOriginLotWhenEmptyAndFlagSet(): void
|
||||||
|
{
|
||||||
|
$origin = $this->makeManagedLot(10, 43);
|
||||||
|
$target = $this->makeManagedLot(0, 44);
|
||||||
|
|
||||||
|
$this->service->move($origin, $target, 10, "Test", null, true);
|
||||||
|
$this->assertEqualsWithDelta(0.0, $origin->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
|
|
||||||
|
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||||
|
$scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions();
|
||||||
|
$this->assertContains($origin, $scheduled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,20 +43,52 @@ final class PartsTableActionHandlerTest extends WebTestCase
|
||||||
$part = $this->createMock(Part::class);
|
$part = $this->createMock(Part::class);
|
||||||
$part->method('getId')->willReturn(1);
|
$part->method('getId')->willReturn(1);
|
||||||
$part->method('getName')->willReturn('Test Part');
|
$part->method('getName')->willReturn('Test Part');
|
||||||
|
|
||||||
$selected_parts = [$part];
|
$selected_parts = [$part];
|
||||||
|
|
||||||
// Test each export format, focusing on our new xlsx format
|
// Test each export format, focusing on our new xlsx format
|
||||||
$formats = ['json', 'csv', 'xml', 'yaml', 'xlsx'];
|
$formats = ['json', 'csv', 'xml', 'yaml', 'xlsx'];
|
||||||
|
|
||||||
foreach ($formats as $format) {
|
foreach ($formats as $format) {
|
||||||
$action = "export_{$format}";
|
$action = "export_{$format}";
|
||||||
$result = $this->service->handleAction($action, $selected_parts, 1, '/test');
|
$result = $this->service->handleAction($action, $selected_parts, 1, '/test');
|
||||||
|
|
||||||
$this->assertInstanceOf(RedirectResponse::class, $result);
|
$this->assertInstanceOf(RedirectResponse::class, $result);
|
||||||
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
|
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
|
||||||
$this->assertStringContainsString("format={$format}", $result->getTargetUrl());
|
$this->assertStringContainsString("format={$format}", $result->getTargetUrl());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -24,10 +24,12 @@ namespace App\Tests\Services\Parts;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\PriceInformations\Currency;
|
||||||
use App\Entity\PriceInformations\Orderdetail;
|
use App\Entity\PriceInformations\Orderdetail;
|
||||||
use App\Entity\PriceInformations\Pricedetail;
|
use App\Entity\PriceInformations\Pricedetail;
|
||||||
use App\Services\Formatters\AmountFormatter;
|
use App\Services\Formatters\AmountFormatter;
|
||||||
use App\Services\Parts\PricedetailHelper;
|
use App\Services\Parts\PricedetailHelper;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
final class PricedetailHelperTest extends WebTestCase
|
final class PricedetailHelperTest extends WebTestCase
|
||||||
|
|
@ -87,4 +89,181 @@ final class PricedetailHelperTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$this->assertSame($expected_result, $this->service->getMaxDiscountAmount($part), $message);
|
$this->assertSame($expected_result, $this->service->getMaxDiscountAmount($part), $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- getMinOrderAmount ---
|
||||||
|
|
||||||
|
public static function minOrderAmountDataProvider(): \Generator
|
||||||
|
{
|
||||||
|
$part = new Part();
|
||||||
|
yield [$part, null, 'No orderdetails'];
|
||||||
|
|
||||||
|
$part = new Part();
|
||||||
|
$part->addOrderdetail(new Orderdetail()); // orderdetail with no pricedetails
|
||||||
|
yield [$part, null, 'Empty orderdetail'];
|
||||||
|
|
||||||
|
$part = new Part();
|
||||||
|
$od = new Orderdetail();
|
||||||
|
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(5));
|
||||||
|
$part->addOrderdetail($od);
|
||||||
|
yield [$part, 5.0, 'Single pricedetail'];
|
||||||
|
|
||||||
|
// The service reads $pricedetails[0] assuming the collection is sorted ascending
|
||||||
|
// (which Doctrine does automatically for persistent collections). For in-memory
|
||||||
|
// collections we must insert in ascending order ourselves.
|
||||||
|
$part = new Part();
|
||||||
|
$od = new Orderdetail();
|
||||||
|
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1));
|
||||||
|
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(3));
|
||||||
|
$od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(10));
|
||||||
|
$part->addOrderdetail($od);
|
||||||
|
yield [$part, 1.0, 'Multiple pricedetails — picks minimum (first in ascending order)'];
|
||||||
|
|
||||||
|
$part = new Part();
|
||||||
|
$od1 = new Orderdetail();
|
||||||
|
$od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(5));
|
||||||
|
$od2 = new Orderdetail();
|
||||||
|
$od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(2));
|
||||||
|
$part->addOrderdetail($od1);
|
||||||
|
$part->addOrderdetail($od2);
|
||||||
|
yield [$part, 2.0, 'Multiple orderdetails — picks global minimum'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('minOrderAmountDataProvider')]
|
||||||
|
public function testGetMinOrderAmount(Part $part, ?float $expected, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expected, $this->service->getMinOrderAmount($part), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- calculateAvgPrice ---
|
||||||
|
|
||||||
|
private static function makePartWithPrice(float $pricePerUnit, float $minQty = 1.0): Part
|
||||||
|
{
|
||||||
|
$part = new Part();
|
||||||
|
$od = new Orderdetail();
|
||||||
|
$pd = (new Pricedetail())
|
||||||
|
->setMinDiscountQuantity($minQty)
|
||||||
|
->setPrice(BigDecimal::of((string) $pricePerUnit));
|
||||||
|
$od->addPricedetail($pd);
|
||||||
|
$part->addOrderdetail($od);
|
||||||
|
return $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateAvgPriceNoOrderdetailsReturnsNull(): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->service->calculateAvgPrice(new Part()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateAvgPriceExplicitAmount(): void
|
||||||
|
{
|
||||||
|
$part = self::makePartWithPrice(2.00);
|
||||||
|
$result = $this->service->calculateAvgPrice($part, 1.0);
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertTrue(BigDecimal::of('2.00000')->isEqualTo($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateAvgPriceUsesMinOrderAmountWhenAmountIsNull(): void
|
||||||
|
{
|
||||||
|
// Min order amount is 5; the price applies for qty >= 5
|
||||||
|
$part = self::makePartWithPrice(3.00, 5.0);
|
||||||
|
$result = $this->service->calculateAvgPrice($part, null);
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertTrue(BigDecimal::of('3.00000')->isEqualTo($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateAvgPriceAveragesMultipleSuppliers(): void
|
||||||
|
{
|
||||||
|
$part = new Part();
|
||||||
|
|
||||||
|
$od1 = new Orderdetail();
|
||||||
|
$od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('2.00')));
|
||||||
|
$part->addOrderdetail($od1);
|
||||||
|
|
||||||
|
$od2 = new Orderdetail();
|
||||||
|
$od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('4.00')));
|
||||||
|
$part->addOrderdetail($od2);
|
||||||
|
|
||||||
|
// Average of 2.00 and 4.00 = 3.00
|
||||||
|
$result = $this->service->calculateAvgPrice($part, 1.0);
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertTrue(BigDecimal::of('3.00000')->isEqualTo($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateAvgPriceSkipsSupplierWithNoCoverageForAmount(): void
|
||||||
|
{
|
||||||
|
// Only one supplier covers qty=1, the other requires qty >= 100
|
||||||
|
$part = new Part();
|
||||||
|
$od1 = new Orderdetail();
|
||||||
|
$od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('5.00')));
|
||||||
|
$part->addOrderdetail($od1);
|
||||||
|
|
||||||
|
$od2 = new Orderdetail();
|
||||||
|
$od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(100)->setPrice(BigDecimal::of('1.00')));
|
||||||
|
$part->addOrderdetail($od2);
|
||||||
|
|
||||||
|
$result = $this->service->calculateAvgPrice($part, 1.0);
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertTrue(BigDecimal::of('5.00000')->isEqualTo($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- convertMoneyToCurrency ---
|
||||||
|
|
||||||
|
public function testConvertMoneyToCurrencyIdentityBothNull(): void
|
||||||
|
{
|
||||||
|
// Both currencies null = base currency; same currency, no conversion
|
||||||
|
$value = BigDecimal::of('10.00');
|
||||||
|
$result = $this->service->convertMoneyToCurrency($value, null, null);
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertTrue($value->isEqualTo($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConvertMoneyToCurrencyFromForeignToBase(): void
|
||||||
|
{
|
||||||
|
// EUR → base (null): exchange rate = 1.2 means 1 foreign = 1.2 base
|
||||||
|
$currency = new Currency();
|
||||||
|
$currency->setExchangeRate(BigDecimal::of('1.2'));
|
||||||
|
|
||||||
|
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null);
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
// 10 * 1.2 = 12
|
||||||
|
$this->assertTrue(BigDecimal::of('12.00000')->isEqualTo($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConvertMoneyToCurrencyNullExchangeRateReturnsNull(): void
|
||||||
|
{
|
||||||
|
$currency = new Currency();
|
||||||
|
// exchange rate not set → null
|
||||||
|
|
||||||
|
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null);
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConvertMoneyToCurrencyZeroExchangeRateReturnsNull(): void
|
||||||
|
{
|
||||||
|
$currency = new Currency();
|
||||||
|
$currency->setExchangeRate(BigDecimal::zero());
|
||||||
|
|
||||||
|
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null);
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConvertMoneyToCurrencyTargetNullExchangeRateReturnsNull(): void
|
||||||
|
{
|
||||||
|
$target = new Currency();
|
||||||
|
// exchange rate not set → getInverseExchangeRate() returns null
|
||||||
|
|
||||||
|
$result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), null, $target);
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConvertMoneyToCurrencySameCurrencyInstanceIsIdentity(): void
|
||||||
|
{
|
||||||
|
$currency = new Currency();
|
||||||
|
$currency->setExchangeRate(BigDecimal::of('2.0'));
|
||||||
|
|
||||||
|
$value = BigDecimal::of('5.00');
|
||||||
|
// origin === target → no conversion at all
|
||||||
|
$result = $this->service->convertMoneyToCurrency($value, $currency, $currency);
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertTrue($value->isEqualTo($result));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,132 @@ final class ProjectBuildHelperTest extends WebTestCase
|
||||||
$this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result));
|
$this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- unknown-instock lots are excluded from buildable count ---
|
||||||
|
|
||||||
|
public function testGetMaximumBuildableCountForBOMEntryExcludesUnknownInstockLots(): void
|
||||||
|
{
|
||||||
|
$part = new Part();
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setAmount(100);
|
||||||
|
$lot->setInstockUnknown(true); // this lot should be ignored
|
||||||
|
$part->addPartLot($lot);
|
||||||
|
|
||||||
|
$entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10);
|
||||||
|
|
||||||
|
// All stock is in an unknown-instock lot → effective amount = 0 → 0 builds
|
||||||
|
$this->assertSame(0, $this->service->getMaximumBuildableCountForBOMEntry($entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetMaximumBuildableCountMixedKnownAndUnknownLots(): void
|
||||||
|
{
|
||||||
|
$part = new Part();
|
||||||
|
|
||||||
|
$knownLot = new PartLot();
|
||||||
|
$knownLot->setAmount(30);
|
||||||
|
|
||||||
|
$unknownLot = new PartLot();
|
||||||
|
$unknownLot->setAmount(999);
|
||||||
|
$unknownLot->setInstockUnknown(true);
|
||||||
|
|
||||||
|
$part->addPartLot($knownLot);
|
||||||
|
$part->addPartLot($unknownLot);
|
||||||
|
|
||||||
|
$entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10);
|
||||||
|
|
||||||
|
// Only the 30 known parts count → floor(30/10) = 3
|
||||||
|
$this->assertSame(3, $this->service->getMaximumBuildableCountForBOMEntry($entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- project with only non-part BOM entries ---
|
||||||
|
|
||||||
|
public function testGetMaximumBuildableCountOnlyNonPartEntriesReturnsIntMax(): void
|
||||||
|
{
|
||||||
|
$project = new Project();
|
||||||
|
$project->addBomEntry((new ProjectBOMEntry())->setName('Solder')->setQuantity(1));
|
||||||
|
$project->addBomEntry((new ProjectBOMEntry())->setName('Wire')->setQuantity(2));
|
||||||
|
|
||||||
|
// No part entries → nothing constrains the count → PHP_INT_MAX
|
||||||
|
$this->assertSame(PHP_INT_MAX, $this->service->getMaximumBuildableCount($project));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetMaximumBuildableCountAsStringOnlyNonPartEntries(): void
|
||||||
|
{
|
||||||
|
$project = new Project();
|
||||||
|
$project->addBomEntry((new ProjectBOMEntry())->setName('Solder')->setQuantity(1));
|
||||||
|
|
||||||
|
$this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isProjectBuildable ---
|
||||||
|
|
||||||
|
public function testIsProjectBuildable(): void
|
||||||
|
{
|
||||||
|
$project = new Project();
|
||||||
|
$part = new Part();
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setAmount(15);
|
||||||
|
$part->addPartLot($lot);
|
||||||
|
$project->addBomEntry((new ProjectBOMEntry())->setPart($part)->setQuantity(5));
|
||||||
|
|
||||||
|
$this->assertTrue($this->service->isProjectBuildable($project, 3)); // 15/5 = 3 ✓
|
||||||
|
$this->assertFalse($this->service->isProjectBuildable($project, 4)); // 4 > 3 ✗
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isBOMEntryBuildable ---
|
||||||
|
|
||||||
|
public function testIsBOMEntryBuildable(): void
|
||||||
|
{
|
||||||
|
$part = new Part();
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setAmount(20);
|
||||||
|
$part->addPartLot($lot);
|
||||||
|
|
||||||
|
$entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10);
|
||||||
|
|
||||||
|
$this->assertTrue($this->service->isBOMEntryBuildable($entry, 2)); // 20/10 = 2 ✓
|
||||||
|
$this->assertFalse($this->service->isBOMEntryBuildable($entry, 3)); // 3 > 2 ✗
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- getNonBuildableProjectBomEntries ---
|
||||||
|
|
||||||
|
public function testGetNonBuildableProjectBomEntriesReturnsShortEntries(): void
|
||||||
|
{
|
||||||
|
$project = new Project();
|
||||||
|
|
||||||
|
$abundantPart = new Part();
|
||||||
|
$lot1 = new PartLot();
|
||||||
|
$lot1->setAmount(100);
|
||||||
|
$abundantPart->addPartLot($lot1);
|
||||||
|
$project->addBomEntry((new ProjectBOMEntry())->setPart($abundantPart)->setQuantity(5));
|
||||||
|
|
||||||
|
$scarcePart = new Part();
|
||||||
|
$lot2 = new PartLot();
|
||||||
|
$lot2->setAmount(3);
|
||||||
|
$scarcePart->addPartLot($lot2);
|
||||||
|
$scarceEntry = (new ProjectBOMEntry())->setPart($scarcePart)->setQuantity(10);
|
||||||
|
$project->addBomEntry($scarceEntry);
|
||||||
|
|
||||||
|
// For 1 build: abundantPart OK (100 >= 5), scarcePart not (3 < 10)
|
||||||
|
$nonBuildable = $this->service->getNonBuildableProjectBomEntries($project, 1);
|
||||||
|
$this->assertCount(1, $nonBuildable);
|
||||||
|
$this->assertSame($scarceEntry, $nonBuildable[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetNonBuildableProjectBomEntriesSkipsNonPartEntries(): void
|
||||||
|
{
|
||||||
|
$project = new Project();
|
||||||
|
$project->addBomEntry((new ProjectBOMEntry())->setName('Wire')->setQuantity(5));
|
||||||
|
|
||||||
|
// Non-part entries are ignored → no non-buildable entries
|
||||||
|
$this->assertCount(0, $this->service->getNonBuildableProjectBomEntries($project, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetNonBuildableProjectBomEntriesThrowsOnZeroBuilds(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->getNonBuildableProjectBomEntries(new Project(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
public function testCalculateTotalBuildPriceMixedEntries(): void
|
public function testCalculateTotalBuildPriceMixedEntries(): void
|
||||||
{
|
{
|
||||||
$project = new Project();
|
$project = new Project();
|
||||||
|
|
|
||||||
105
tests/Services/UserSystem/PermissionPresetsHelperTest.php
Normal file
105
tests/Services/UserSystem/PermissionPresetsHelperTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\UserSystem;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use App\Services\UserSystem\PermissionManager;
|
||||||
|
use App\Services\UserSystem\PermissionPresetsHelper;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
final class PermissionPresetsHelperTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static PermissionPresetsHelper $service;
|
||||||
|
private static PermissionManager $permissionManager;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
self::$service = self::getContainer()->get(PermissionPresetsHelper::class);
|
||||||
|
self::$permissionManager = self::getContainer()->get(PermissionManager::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUser(): User
|
||||||
|
{
|
||||||
|
return new User();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllInheritPresetLeavesAllPermissionsInherit(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_INHERIT);
|
||||||
|
|
||||||
|
// After all-inherit preset, 'parts' read should be null (inherit)
|
||||||
|
$this->assertNull(self::$permissionManager->dontInherit($user, 'parts', 'read'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllForbidPresetSetsAllPermissionsToFalse(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_FORBID);
|
||||||
|
|
||||||
|
// After all-forbid, 'parts' read should be false (disallowed)
|
||||||
|
$this->assertFalse(self::$permissionManager->dontInherit($user, 'parts', 'read'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllAllowPresetSetsAllPermissionsToTrue(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_ALLOW);
|
||||||
|
|
||||||
|
// After all-allow, 'parts' read should be true (allowed)
|
||||||
|
$this->assertTrue(self::$permissionManager->dontInherit($user, 'parts', 'read'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadOnlyPresetAllowsPartsRead(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_READ_ONLY);
|
||||||
|
|
||||||
|
$this->assertTrue(self::$permissionManager->dontInherit($user, 'parts', 'read'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadOnlyPresetDoesNotAllowPartsCreate(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_READ_ONLY);
|
||||||
|
|
||||||
|
// create should remain null (inherit) or false — not explicitly allowed
|
||||||
|
$createValue = self::$permissionManager->dontInherit($user, 'parts', 'create');
|
||||||
|
$this->assertNotTrue($createValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownPresetThrowsException(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
self::$service->applyPreset($this->createUser(), 'non_existent_preset');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyPresetReturnsTheSameUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
$returned = self::$service->applyPreset($user, PermissionPresetsHelper::PRESET_ALL_INHERIT);
|
||||||
|
$this->assertSame($user, $returned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Validator\Constraints\BigDecimal;
|
||||||
|
|
||||||
|
use App\Validator\Constraints\BigDecimal\BigDecimalGreaterThanValidator;
|
||||||
|
use App\Validator\Constraints\BigDecimal\BigDecimalPositive;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
|
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||||
|
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests BigDecimalGreaterThanValidator via the BigDecimalPositive constraint (value > 0).
|
||||||
|
*/
|
||||||
|
final class BigDecimalGreaterThanValidatorTest extends ConstraintValidatorTestCase
|
||||||
|
{
|
||||||
|
protected function createValidator(): ConstraintValidatorInterface
|
||||||
|
{
|
||||||
|
return new BigDecimalGreaterThanValidator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(null, new BigDecimalPositive());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPositiveIntegerIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(1, new BigDecimalPositive());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPositiveStringIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate('0.01', new BigDecimalPositive());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPositiveBigDecimalIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(BigDecimal::of('1.5'), new BigDecimalPositive());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZeroIsInvalid(): void
|
||||||
|
{
|
||||||
|
$constraint = new BigDecimalPositive();
|
||||||
|
$this->validator->validate(0, $constraint);
|
||||||
|
$this->buildViolation($constraint->message)
|
||||||
|
->setParameters(['{{ value }}' => '0', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
|
||||||
|
->setCode(\Symfony\Component\Validator\Constraints\GreaterThan::TOO_LOW_ERROR)
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZeroBigDecimalIsInvalid(): void
|
||||||
|
{
|
||||||
|
$constraint = new BigDecimalPositive();
|
||||||
|
$this->validator->validate(BigDecimal::of('0.00'), $constraint);
|
||||||
|
$this->buildViolation($constraint->message)
|
||||||
|
->setParameters(['{{ value }}' => '0.00', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
|
||||||
|
->setCode(\Symfony\Component\Validator\Constraints\GreaterThan::TOO_LOW_ERROR)
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNegativeIsInvalid(): void
|
||||||
|
{
|
||||||
|
$constraint = new BigDecimalPositive();
|
||||||
|
$this->validator->validate(-1, $constraint);
|
||||||
|
$this->buildViolation($constraint->message)
|
||||||
|
->setParameters(['{{ value }}' => '-1', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
|
||||||
|
->setCode(\Symfony\Component\Validator\Constraints\GreaterThan::TOO_LOW_ERROR)
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Validator\Constraints\BigDecimal;
|
||||||
|
|
||||||
|
use App\Validator\Constraints\BigDecimal\BigDecimalGreaterThenOrEqualValidator;
|
||||||
|
use App\Validator\Constraints\BigDecimal\BigDecimalPositiveOrZero;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
|
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||||
|
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests BigDecimalGreaterThenOrEqualValidator via the BigDecimalPositiveOrZero constraint (value >= 0).
|
||||||
|
*/
|
||||||
|
final class BigDecimalGreaterThenOrEqualValidatorTest extends ConstraintValidatorTestCase
|
||||||
|
{
|
||||||
|
protected function createValidator(): ConstraintValidatorInterface
|
||||||
|
{
|
||||||
|
return new BigDecimalGreaterThenOrEqualValidator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(null, new BigDecimalPositiveOrZero());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPositiveIntegerIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(1, new BigDecimalPositiveOrZero());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZeroIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(0, new BigDecimalPositiveOrZero());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZeroBigDecimalIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(BigDecimal::of('0.00'), new BigDecimalPositiveOrZero());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPositiveBigDecimalIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(BigDecimal::of('3.14'), new BigDecimalPositiveOrZero());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNegativeIsInvalid(): void
|
||||||
|
{
|
||||||
|
$constraint = new BigDecimalPositiveOrZero();
|
||||||
|
$this->validator->validate(-1, $constraint);
|
||||||
|
$this->buildViolation($constraint->message)
|
||||||
|
->setParameters(['{{ value }}' => '-1', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
|
||||||
|
->setCode(\Symfony\Component\Validator\Constraints\GreaterThanOrEqual::TOO_LOW_ERROR)
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNegativeBigDecimalIsInvalid(): void
|
||||||
|
{
|
||||||
|
$constraint = new BigDecimalPositiveOrZero();
|
||||||
|
$this->validator->validate(BigDecimal::of('-0.01'), $constraint);
|
||||||
|
$this->buildViolation($constraint->message)
|
||||||
|
->setParameters(['{{ value }}' => '-0.01', '{{ compared_value }}' => '0', '{{ compared_value_type }}' => 'int'])
|
||||||
|
->setCode(\Symfony\Component\Validator\Constraints\GreaterThanOrEqual::TOO_LOW_ERROR)
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -154,6 +154,33 @@ final class UniqueObjectCollectionValidatorTest extends ConstraintValidatorTestC
|
||||||
->assertRaised();
|
->assertRaised();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testThirdElementDuplicatePointsToIndexTwo(): void
|
||||||
|
{
|
||||||
|
// First two elements are unique; only the third duplicates the first.
|
||||||
|
$this->validator->validate(new ArrayCollection([
|
||||||
|
new DummyUniqueValidatableObject(['a' => 1]),
|
||||||
|
new DummyUniqueValidatableObject(['a' => 2]),
|
||||||
|
new DummyUniqueValidatableObject(['a' => 1]), // duplicate of index 0
|
||||||
|
]),
|
||||||
|
new UniqueObjectCollection(fields: ['a']));
|
||||||
|
|
||||||
|
$this
|
||||||
|
->buildViolation('This value is already used.')
|
||||||
|
->setCode(UniqueObjectCollection::IS_NOT_UNIQUE)
|
||||||
|
->setParameter('{{ object }}', 'objectString')
|
||||||
|
->atPath('property.path[2].a')
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllNullsWithAllowNullProducesNoViolation(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(new ArrayCollection([
|
||||||
|
new DummyUniqueValidatableObject(['a' => null]),
|
||||||
|
new DummyUniqueValidatableObject(['a' => null]),
|
||||||
|
new DummyUniqueValidatableObject(['a' => null]),
|
||||||
|
]),
|
||||||
|
new UniqueObjectCollection(fields: ['a'], allowNull: true));
|
||||||
|
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
tests/Validator/Constraints/UniquePartIpnValidatorTest.php
Normal file
103
tests/Validator/Constraints/UniquePartIpnValidatorTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Validator\Constraints;
|
||||||
|
|
||||||
|
use App\Entity\Base\AbstractDBElement;
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||||
|
use App\Validator\Constraints\UniquePartIpnConstraint;
|
||||||
|
use App\Validator\Constraints\UniquePartIpnValidator;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||||
|
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||||
|
|
||||||
|
final class UniquePartIpnValidatorTest extends ConstraintValidatorTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface&MockObject $em;
|
||||||
|
private IpnSuggestSettings&MockObject $ipnSettings;
|
||||||
|
|
||||||
|
protected function createValidator(): ConstraintValidatorInterface
|
||||||
|
{
|
||||||
|
$this->em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
// createMock() bypasses the ForbidConstructorTrait; public properties are accessible directly
|
||||||
|
$this->ipnSettings = $this->createMock(IpnSuggestSettings::class);
|
||||||
|
$this->ipnSettings->autoAppendSuffix = false;
|
||||||
|
|
||||||
|
return new UniquePartIpnValidator($this->em, $this->ipnSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullValueIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(null, new UniquePartIpnConstraint());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyStringIsValid(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate('', new UniquePartIpnConstraint());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAutoAppendSuffixSkipsValidation(): void
|
||||||
|
{
|
||||||
|
$this->ipnSettings->autoAppendSuffix = true;
|
||||||
|
$this->validator->validate('IPN-001', new UniquePartIpnConstraint());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUniqueIpnIsValid(): void
|
||||||
|
{
|
||||||
|
$repo = $this->createMock(\Doctrine\ORM\EntityRepository::class);
|
||||||
|
$repo->method('findBy')->willReturn([]);
|
||||||
|
$this->em->method('getRepository')->willReturn($repo);
|
||||||
|
|
||||||
|
$part = new Part();
|
||||||
|
$this->setObject($part);
|
||||||
|
|
||||||
|
$this->validator->validate('UNIQUE-IPN', new UniquePartIpnConstraint());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDuplicateIpnRaisesViolation(): void
|
||||||
|
{
|
||||||
|
$existingPart = new Part();
|
||||||
|
$ref = new \ReflectionProperty(AbstractDBElement::class, 'id');
|
||||||
|
$ref->setValue($existingPart, 99);
|
||||||
|
|
||||||
|
$repo = $this->createMock(\Doctrine\ORM\EntityRepository::class);
|
||||||
|
$repo->method('findBy')->willReturn([$existingPart]);
|
||||||
|
$this->em->method('getRepository')->willReturn($repo);
|
||||||
|
|
||||||
|
// Validated part has no ID (new, unsaved part)
|
||||||
|
$part = new Part();
|
||||||
|
$this->setObject($part);
|
||||||
|
|
||||||
|
$constraint = new UniquePartIpnConstraint();
|
||||||
|
$this->validator->validate('DUPLICATE-IPN', $constraint);
|
||||||
|
$this->buildViolation($constraint->message)
|
||||||
|
->setParameter('{{ value }}', 'DUPLICATE-IPN')
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
tests/Validator/Constraints/ValidFileFilterValidatorTest.php
Normal file
81
tests/Validator/Constraints/ValidFileFilterValidatorTest.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Validator\Constraints;
|
||||||
|
|
||||||
|
use App\Validator\Constraints\ValidFileFilter;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
final class ValidFileFilterValidatorTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static ValidatorInterface $validator;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
self::$validator = self::getContainer()->get('validator');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullIsValid(): void
|
||||||
|
{
|
||||||
|
$violations = self::$validator->validate(null, new ValidFileFilter());
|
||||||
|
$this->assertCount(0, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyStringIsValid(): void
|
||||||
|
{
|
||||||
|
$violations = self::$validator->validate('', new ValidFileFilter());
|
||||||
|
$this->assertCount(0, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidExtensionFilterIsValid(): void
|
||||||
|
{
|
||||||
|
$violations = self::$validator->validate('.jpg,.png', new ValidFileFilter());
|
||||||
|
$this->assertCount(0, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidMimeTypeFilterIsValid(): void
|
||||||
|
{
|
||||||
|
$violations = self::$validator->validate('image/*', new ValidFileFilter());
|
||||||
|
$this->assertCount(0, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMixedValidFilterIsValid(): void
|
||||||
|
{
|
||||||
|
$violations = self::$validator->validate('image/*, .pdf, video/mp4', new ValidFileFilter());
|
||||||
|
$this->assertCount(0, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidFilterRaisesViolation(): void
|
||||||
|
{
|
||||||
|
$violations = self::$validator->validate('*.notvalid', new ValidFileFilter());
|
||||||
|
$this->assertCount(1, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullFilenameRaisesViolation(): void
|
||||||
|
{
|
||||||
|
$violations = self::$validator->validate('test.png', new ValidFileFilter());
|
||||||
|
$this->assertCount(1, $violations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,52 +24,54 @@ namespace App\Tests\Validator\Constraints;
|
||||||
|
|
||||||
use App\Validator\Constraints\ValidGTIN;
|
use App\Validator\Constraints\ValidGTIN;
|
||||||
use App\Validator\Constraints\ValidGTINValidator;
|
use App\Validator\Constraints\ValidGTINValidator;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||||
|
|
||||||
final class ValidGTINValidatorTest extends ConstraintValidatorTestCase
|
final class ValidGTINValidatorTest extends ConstraintValidatorTestCase
|
||||||
{
|
{
|
||||||
|
|
||||||
public function testAllowNull(): void
|
|
||||||
{
|
|
||||||
$this->validator->validate(null, new ValidGTIN());
|
|
||||||
$this->assertNoViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidGTIN8(): void
|
|
||||||
{
|
|
||||||
$this->validator->validate('12345670', new ValidGTIN());
|
|
||||||
$this->assertNoViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidGTIN12(): void
|
|
||||||
{
|
|
||||||
$this->validator->validate('123456789012', new ValidGTIN());
|
|
||||||
$this->assertNoViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidGTIN13(): void
|
|
||||||
{
|
|
||||||
$this->validator->validate('1234567890128', new ValidGTIN());
|
|
||||||
$this->assertNoViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidGTIN14(): void
|
|
||||||
{
|
|
||||||
$this->validator->validate('12345678901231', new ValidGTIN());
|
|
||||||
$this->assertNoViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testInvalidGTIN(): void
|
|
||||||
{
|
|
||||||
$this->validator->validate('1234567890123', new ValidGTIN());
|
|
||||||
$this->buildViolation('validator.invalid_gtin')
|
|
||||||
->assertRaised();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function createValidator(): ConstraintValidatorInterface
|
protected function createValidator(): ConstraintValidatorInterface
|
||||||
{
|
{
|
||||||
return new ValidGTINValidator();
|
return new ValidGTINValidator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- values that must produce no violation ---
|
||||||
|
|
||||||
|
public static function validValuesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'null is skipped' => [null];
|
||||||
|
yield 'empty string is skipped' => [''];
|
||||||
|
yield 'valid GTIN-8' => ['12345670'];
|
||||||
|
yield 'valid GTIN-12' => ['123456789012'];
|
||||||
|
yield 'valid GTIN-13' => ['1234567890128'];
|
||||||
|
yield 'valid GTIN-14' => ['12345678901231'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('validValuesProvider')]
|
||||||
|
public function testValidValue(mixed $value): void
|
||||||
|
{
|
||||||
|
$this->validator->validate($value, new ValidGTIN());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- values that must produce a violation ---
|
||||||
|
|
||||||
|
public static function invalidValuesProvider(): \Generator
|
||||||
|
{
|
||||||
|
yield 'wrong check digit (GTIN-13)' => ['1234567890123'];
|
||||||
|
yield 'non-numeric string' => ['ABCDEFGHIJKLM'];
|
||||||
|
yield 'wrong length — 9 digits' => ['123456789'];
|
||||||
|
yield 'wrong length — 11 digits' => ['12345678901'];
|
||||||
|
yield 'leading whitespace' => [' 1234567890128'];
|
||||||
|
yield 'trailing whitespace' => ['1234567890128 '];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidValuesProvider')]
|
||||||
|
public function testInvalidValue(string $value): void
|
||||||
|
{
|
||||||
|
$this->validator->validate($value, new ValidGTIN());
|
||||||
|
$this->buildViolation('validator.invalid_gtin')
|
||||||
|
->assertRaised();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
tests/Validator/Constraints/ValidPartLotValidatorTest.php
Normal file
123
tests/Validator/Constraints/ValidPartLotValidatorTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Validator\Constraints;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\Parts\PartLot;
|
||||||
|
use App\Entity\Parts\StorageLocation;
|
||||||
|
use App\Validator\Constraints\ValidPartLot;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
final class ValidPartLotValidatorTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static ValidatorInterface $validator;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
self::$validator = self::getContainer()->get('validator');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartLotWithoutStorageLocationIsValid(): void
|
||||||
|
{
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setPart(new Part());
|
||||||
|
// No storage location set → validation should pass without any location checks
|
||||||
|
|
||||||
|
$violations = self::$validator->validate($lot, new ValidPartLot());
|
||||||
|
$this->assertCount(0, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartLotWithNonFullNonRestrictedStorageLocationIsValid(): void
|
||||||
|
{
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setPart(new Part());
|
||||||
|
|
||||||
|
$location = new StorageLocation();
|
||||||
|
// Default: not full, not limited — should be valid
|
||||||
|
$lot->setStorageLocation($location);
|
||||||
|
|
||||||
|
$violations = self::$validator->validate($lot, new ValidPartLot());
|
||||||
|
$this->assertCount(0, $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartLotWithFullLocationAndNewLotRaisesViolation(): void
|
||||||
|
{
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setPart(new Part());
|
||||||
|
|
||||||
|
$location = new StorageLocation();
|
||||||
|
$location->setIsFull(true);
|
||||||
|
$lot->setStorageLocation($location);
|
||||||
|
// The lot has no ID (new entity), so "parts" is empty, and a full location will reject it
|
||||||
|
|
||||||
|
$violations = self::$validator->validate($lot, new ValidPartLot());
|
||||||
|
// Should raise a violation because the location is full and the part is not in the existing parts list
|
||||||
|
$this->assertGreaterThan(0, count($violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonPartLotValueThrowsException(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\Symfony\Component\Form\Exception\UnexpectedTypeException::class);
|
||||||
|
self::$validator->validate('not a part lot', new ValidPartLot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartLotWithFullLocationRaisesNamedViolation(): void
|
||||||
|
{
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setPart(new Part());
|
||||||
|
|
||||||
|
$location = new StorageLocation();
|
||||||
|
$location->setIsFull(true);
|
||||||
|
$lot->setStorageLocation($location);
|
||||||
|
|
||||||
|
$violations = self::$validator->validate($lot, new ValidPartLot());
|
||||||
|
// Expect exactly one violation on the storage_location path
|
||||||
|
$this->assertCount(1, $violations);
|
||||||
|
$this->assertSame('storage_location', $violations[0]->getPropertyPath());
|
||||||
|
$this->assertStringContainsString('location_full', $violations[0]->getMessageTemplate());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLimitToExistingPartsWithNewLotRaisesViolation(): void
|
||||||
|
{
|
||||||
|
$lot = new PartLot();
|
||||||
|
$lot->setPart(new Part());
|
||||||
|
|
||||||
|
$location = new StorageLocation();
|
||||||
|
$location->setLimitToExistingParts(true);
|
||||||
|
$lot->setStorageLocation($location);
|
||||||
|
|
||||||
|
// New lot (no ID) → parts collection is empty → part is not in the list → violation
|
||||||
|
$violations = self::$validator->validate($lot, new ValidPartLot());
|
||||||
|
$this->assertCount(1, $violations);
|
||||||
|
$this->assertSame('storage_location', $violations[0]->getPropertyPath());
|
||||||
|
$this->assertSame('validator.part_lot.only_existing', $violations[0]->getMessageTemplate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: The 'location_full.no_increase' violation (raised when a lot's amount
|
||||||
|
// is increased while its storage location is marked full) requires the entity to
|
||||||
|
// carry a real Doctrine originalEntityData snapshot, which is only set after an
|
||||||
|
// actual persist+flush. Testing that path belongs in a database integration test.
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Validator\Constraints;
|
||||||
|
|
||||||
|
use App\Validator\Constraints\Year2038BugWorkaround;
|
||||||
|
use App\Validator\Constraints\Year2038BugWorkaroundValidator;
|
||||||
|
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||||
|
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||||
|
|
||||||
|
final class Year2038BugWorkaroundValidatorTest extends ConstraintValidatorTestCase
|
||||||
|
{
|
||||||
|
protected function createValidator(): ConstraintValidatorInterface
|
||||||
|
{
|
||||||
|
// Disable validation by default so tests run on both 32- and 64-bit systems
|
||||||
|
return new Year2038BugWorkaroundValidator(disable_validation: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsNotActivatedWhenDisabled(): void
|
||||||
|
{
|
||||||
|
$validator = new Year2038BugWorkaroundValidator(disable_validation: true);
|
||||||
|
$this->assertFalse($validator->isActivated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsNotActivatedOn64Bit(): void
|
||||||
|
{
|
||||||
|
// On any normal 64-bit CI/dev system PHP_INT_SIZE === 8, so activation requires 32-bit
|
||||||
|
if (PHP_INT_SIZE !== 8) {
|
||||||
|
$this->markTestSkipped('This test is only meaningful on 64-bit systems.');
|
||||||
|
}
|
||||||
|
$validator = new Year2038BugWorkaroundValidator(disable_validation: false);
|
||||||
|
$this->assertFalse($validator->isActivated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullValueProducesNoViolation(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(null, new Year2038BugWorkaround());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDateBefore2038ProducesNoViolationWhenDisabled(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate(new \DateTime('2037-01-01'), new Year2038BugWorkaround());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDateAfter2038ProducesNoViolationWhenDisabled(): void
|
||||||
|
{
|
||||||
|
// Validation disabled → even a "bad" date causes no violation
|
||||||
|
$this->validator->validate(new \DateTime('2039-01-01'), new Year2038BugWorkaround());
|
||||||
|
$this->assertNoViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,59 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl">
|
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl">
|
||||||
<file id="frontend.nl">
|
<file id="frontend.en">
|
||||||
<unit id="lQ8QeGr" name="search.placeholder">
|
<unit id="eLrezdb" name="search.placeholder">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>search.placeholder</source>
|
<source>search.placeholder</source>
|
||||||
<target>Zoeken</target>
|
<target>Zoeken</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="R4hoCqe" name="part.labelp">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>part.labelp</source>
|
||||||
|
<target>Onderdelen</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>entity.select.group.new_not_added_to_DB</source>
|
||||||
|
<target>Nieuw (nog niet toegevoegd aan de database)</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="9rnHbSK" name="user.password_strength.very_weak">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>user.password_strength.very_weak</source>
|
||||||
|
<target>Zeer zwak</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="gKHmHwM" name="user.password_strength.weak">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>user.password_strength.weak</source>
|
||||||
|
<target>Zwak</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="c44gN8b" name="user.password_strength.medium">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>user.password_strength.medium</source>
|
||||||
|
<target>Gemiddeld</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="NwiBLHc" name="user.password_strength.strong">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>user.password_strength.strong</source>
|
||||||
|
<target>Sterk</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="Bw.iCUm" name="user.password_strength.very_strong">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>user.password_strength.very_strong</source>
|
||||||
|
<target>Zeer sterk</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="U5IhkwB" name="search.submit">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>search.submit</source>
|
||||||
|
<target>Ga!</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
|
|
@ -1034,6 +1034,18 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||||
<target>Bearbeite Bauteileinformationen von</target>
|
<target>Bearbeite Bauteileinformationen von</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="aznOqRU" name="form.dirty_form.unsaved_changes.title">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>form.dirty_form.unsaved_changes.title</source>
|
||||||
|
<target>Ungespeicherte Änderungen</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="dfzAYsv" name="form.dirty_form.unsaved_changes.message">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>form.dirty_form.unsaved_changes.message</source>
|
||||||
|
<target>Sie haben ungespeicherte Änderungen vorgenommen, die verloren gehen, wenn Sie diese Seite verlassen. Möchten Sie wirklich fortfahren?</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="EwY218_" name="part.edit.tab.common">
|
<unit id="EwY218_" name="part.edit.tab.common">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.edit.tab.common</source>
|
<source>part.edit.tab.common</source>
|
||||||
|
|
@ -13605,5 +13617,35 @@ Buerklin-API-Authentication-Server:
|
||||||
<target>Host-URL</target>
|
<target>Host-URL</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="kuDv.So" name="browser_plugin.recent_pages.title">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>browser_plugin.recent_pages.title</source>
|
||||||
|
<target>Zuletzt besuchte Seiten</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="AjNj8wk" name="browser_plugin.recent_pages.help">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>browser_plugin.recent_pages.help</source>
|
||||||
|
<target>Hier werden die zuletzt besuchten Seiten angezeigt.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="lVUU9s7" name="settings.ips.browser_plugin">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.ips.browser_plugin</source>
|
||||||
|
<target>Browser-Plugin</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="IrJs3fI" name="settings.ips.browser_plugin.description">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.ips.browser_plugin.description</source>
|
||||||
|
<target>Einstellungen für das Browser-Plugin</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="_8UrMCB" name="settings.ips.browser_plugin.enabled.help">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.ips.browser_plugin.enabled.help</source>
|
||||||
|
<target>Aktiviert das Browser-Plugin für diese Instanz.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
|
|
@ -1035,6 +1035,18 @@ Sub elements will be moved upwards.</target>
|
||||||
<target>Edit [part]</target>
|
<target>Edit [part]</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="aznOqRU" name="form.dirty_form.unsaved_changes.title">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>form.dirty_form.unsaved_changes.title</source>
|
||||||
|
<target>Unsaved Changes</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="dfzAYsv" name="form.dirty_form.unsaved_changes.message">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>form.dirty_form.unsaved_changes.message</source>
|
||||||
|
<target>You have unsaved changes that will be lost if you leave this page. Are you sure you want to continue?</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="EwY218_" name="part.edit.tab.common">
|
<unit id="EwY218_" name="part.edit.tab.common">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.edit.tab.common</source>
|
<source>part.edit.tab.common</source>
|
||||||
|
|
@ -13607,5 +13619,35 @@ Buerklin-API Authentication server:
|
||||||
<target>Host URL</target>
|
<target>Host URL</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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 state="translated">
|
||||||
|
<source>settings.ips.browser_plugin</source>
|
||||||
|
<target>Browser plugin</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="IrJs3fI" name="settings.ips.browser_plugin.description">
|
||||||
|
<segment state="translated">
|
||||||
|
<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 state="translated">
|
||||||
|
<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>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
|
|
@ -223,13 +223,13 @@
|
||||||
<target>Vanwege technische beperkingen is het niet mogelijk om datums na 2038-01-19 te selecteren op 32-bits systemen!</target>
|
<target>Vanwege technische beperkingen is het niet mogelijk om datums na 2038-01-19 te selecteren op 32-bits systemen!</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="89nojXY" name="validator.fileSize.invalidFormat">
|
<unit id="iM9yb_p" name="validator.fileSize.invalidFormat">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>validator.fileSize.invalidFormat</source>
|
<source>validator.fileSize.invalidFormat</source>
|
||||||
<target>Ongeldig bestandsformaat. Gebruiker een geheel getal plus K, M of G als toevoeging voor Kilo, Mega of Gigabytes.</target>
|
<target>Ongeldig bestandsformaat. Gebruiker een geheel getal plus K, M of G als toevoeging voor Kilo, Mega of Gigabytes.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="iXcU7ce" name="validator.invalid_range">
|
<unit id="ZFxQ0BZ" name="validator.invalid_range">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>validator.invalid_range</source>
|
<source>validator.invalid_range</source>
|
||||||
<target>De opgegeven reeks is niet geldig!</target>
|
<target>De opgegeven reeks is niet geldig!</target>
|
||||||
|
|
@ -241,5 +241,17 @@
|
||||||
<target>Ongeldige code. Controleer of je authenticator-app correct is ingesteld en of zowel de server als het authenticatieapparaat de tijd correct hebben ingesteld.</target>
|
<target>Ongeldige code. Controleer of je authenticator-app correct is ingesteld en of zowel de server als het authenticatieapparaat de tijd correct hebben ingesteld.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
|
||||||
|
<target>Er is al een vertaling gedefinieerd voor dit type en deze taal!</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="zT_j_oQ" name="validator.invalid_gtin">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>validator.invalid_gtin</source>
|
||||||
|
<target>Dit is geen geldige GTIN / EAN!</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue