Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Core/Template/ExtensionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

use BadMethodCallException;
use Closure;
use FacturaScripts\Core\Tools;
use ReflectionClass;
use ReflectionMethod;

Expand All @@ -40,6 +41,14 @@ trait ExtensionsTrait
*/
protected static $extensionCache = [];

/**
* Stores the method name conflicts already reported, to avoid logging
* the same warning on every call.
*
* @var array
*/
protected static $reportedConflicts = [];

/**
* Executes the first matched extension.
*
Expand All @@ -59,6 +68,22 @@ public function __call($name, $arguments = [])

// Execute first extension found (respecting priority)
if (!empty(static::$extensionCache[$name])) {
// When several extensions register a method with the same name, __call()
// only runs the first one. This is a common source of confusion for
// developers whose implementation is silently ignored, so we warn them
// about the name conflict (use pipe() to chain several implementations).
if (count(static::$extensionCache[$name]) > 1) {
$conflictKey = static::class . '::' . $name;
if (false === isset(static::$reportedConflicts[$conflictKey])) {
static::$reportedConflicts[$conflictKey] = true;
Tools::log()->warning(
'There are ' . count(static::$extensionCache[$name]) . ' extensions with the method "'
. $name . '" on class ' . static::class . '. Only the first one is executed. '
. 'Use pipe() if you need to chain several implementations.'
);
}
}

return call_user_func_array(static::$extensionCache[$name][0]->bindTo($this, static::class), $arguments);
}

Expand Down Expand Up @@ -104,6 +129,7 @@ public static function clearExtensions(): void
{
static::$extensions = [];
static::$extensionCache = [];
static::$reportedConflicts = [];
}

/**
Expand Down
116 changes: 116 additions & 0 deletions Test/Core/Template/ExtensionsTraitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php
/**
* This file is part of FacturaScripts
* Copyright (C) 2026 Carlos Garcia Gomez <carlos@facturascripts.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

namespace FacturaScripts\Test\Core\Template;

use FacturaScripts\Core\Base\MiniLog;
use FacturaScripts\Core\Template\ExtensionsTrait;
use PHPUnit\Framework\TestCase;

/**
* Tests para el aviso de conflicto de nombres al extender clases (tarea #3763).
*/
final class ExtensionsTraitTest extends TestCase
{
protected function setUp(): void
{
ExtensionsTraitTestSubject::clearExtensions();
MiniLog::clear();
}

public function testSingleExtensionRunsWithoutWarning(): void
{
ExtensionsTraitTestSubject::addExtension(new class {
public function greet()
{
return function () {
return 'hello';
};
}
});

$subject = new ExtensionsTraitTestSubject();
$this->assertEquals('hello', $subject->greet());
$this->assertCount(0, MiniLog::read('', ['warning']), 'no debería avisar con una sola extensión');
}

public function testDuplicatedMethodNameLogsWarning(): void
{
// dos plugins registran el mismo método; el de mayor prioridad va primero
ExtensionsTraitTestSubject::addExtension(new class {
public function greet()
{
return function () {
return 'first';
};
}
}, 200);
ExtensionsTraitTestSubject::addExtension(new class {
public function greet()
{
return function () {
return 'second';
};
}
}, 100);

$subject = new ExtensionsTraitTestSubject();

// __call solo ejecuta la primera (mayor prioridad)
$this->assertEquals('first', $subject->greet());

// y se avisa al desarrollador del conflicto de nombres
$this->assertNotEmpty(MiniLog::read('', ['warning']), 'debería avisar del conflicto de nombres');
}

public function testWarningIsLoggedOnlyOnce(): void
{
ExtensionsTraitTestSubject::addExtension(new class {
public function greet()
{
return function () {
return 'first';
};
}
}, 200);
ExtensionsTraitTestSubject::addExtension(new class {
public function greet()
{
return function () {
return 'second';
};
}
}, 100);

$subject = new ExtensionsTraitTestSubject();
$subject->greet();
$subject->greet();
$subject->greet();

$this->assertCount(1, MiniLog::read('', ['warning']), 'el aviso no debe repetirse en cada llamada');
}
}

/**
* Clase de apoyo que usa el trait bajo prueba.
*/
class ExtensionsTraitTestSubject
{
use ExtensionsTrait;
}