mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-07 20:42:47 +09:00
* Show discounted payment amount on client order details page
Mirrors the existing admin-side order details table: when an order
has a discount, show 'Order Discount' and 'Payment Amount After
Discount' rows alongside the full 'Payment Amount'.
Fixes #1723
* Classify PermissiveStub arithmetic TypeErrors as test-infra
The new discount rows in mod_order_manage.html.twig (client) do
`order.total - order.discount` directly, same as the existing admin
template. Under the strict-variables render-everything harness, both
resolve to PermissiveStub when unspecified, and PHP has no operator
overloading for arithmetic on plain objects, so this throws
'Unsupported operand types'.
This is the same class of stub-coercion artifact as the existing
'must be of type float/int' and 'Return value must be of type'
entries above it, not a real template bug.
* Require PermissiveStub in the operand-type message before downgrading
Per CodeRabbit review: the previous check matched any 'Unsupported
operand types' TypeError, which would also swallow a genuine
arithmetic bug elsewhere in a template as test-infra. PHP's TypeError
message includes the operand class names, so requiring
PermissiveStub::class in the message keeps the downgrade scoped to
the actual stub-coercion case.
(cherry picked from commit 195921a32d)
508 lines
19 KiB
PHP
508 lines
19 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Copyright 2022-2026 FOSSBilling
|
|
* SPDX-License-Identifier: Apache-2.0.
|
|
*
|
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
|
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Support;
|
|
|
|
use FOSSBilling\Twig\Extension\ApiExtension;
|
|
use FOSSBilling\Twig\Extension\FOSSBillingExtension;
|
|
use FOSSBilling\Twig\Extension\LegacyExtension;
|
|
use Symfony\Component\Finder\Finder;
|
|
use Twig\Environment;
|
|
use Twig\Extension\AttributeExtension;
|
|
use Twig\Extension\CoreExtension;
|
|
use Twig\Extra\Intl\IntlExtension;
|
|
use Twig\Extra\Markdown\MarkdownExtension;
|
|
use Twig\Extra\Markdown\MarkdownInterface;
|
|
use Twig\Extra\Markdown\MarkdownRuntime;
|
|
use Twig\NodeTraverser;
|
|
|
|
class UrlAwarePermissiveContainer extends \Pimple\Container
|
|
{
|
|
private readonly PermissiveStub $stub;
|
|
/** @var array<string, mixed> */
|
|
private array $store = [];
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
$this->stub = new PermissiveStub();
|
|
$this->store['loaded_assets'] = [];
|
|
$this['filesystem'] = new \Symfony\Component\Filesystem\Filesystem();
|
|
}
|
|
|
|
#[\Override]
|
|
public function offsetExists(mixed $offset): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
#[\Override]
|
|
public function offsetGet(mixed $offset): mixed
|
|
{
|
|
if ($offset === 'url') {
|
|
return new class {
|
|
public function link(string $path, ?array $query = null): string
|
|
{
|
|
return '';
|
|
}
|
|
|
|
public function adminLink(string $path, ?array $query = null): string
|
|
{
|
|
return '';
|
|
}
|
|
};
|
|
}
|
|
// The LegacyExtension's `ip_country_name`/`ip_country_code` filters
|
|
// call `$this->di['geoip']->country($ip)` and then read properties on
|
|
// the returned record. The signatures are `: string`, so a permissive
|
|
// stub would fail with a TypeError. Provide a stub object whose
|
|
// `country()` call throws — the surrounding try/catch then returns
|
|
// the empty-string fallback the real code emits when GeoIP is
|
|
// unavailable. Mirrors the same handling in `PermissiveContainer`.
|
|
if ($offset === 'geoip') {
|
|
return new class {
|
|
public function __call(string $name, array $args): mixed
|
|
{
|
|
throw new \RuntimeException('geoip service not available in test environment');
|
|
}
|
|
|
|
public function __get(string $name): mixed
|
|
{
|
|
throw new \RuntimeException('geoip service not available in test environment');
|
|
}
|
|
};
|
|
}
|
|
if (array_key_exists((string) $offset, $this->store)) {
|
|
return $this->store[(string) $offset];
|
|
}
|
|
|
|
return $this->stub;
|
|
}
|
|
|
|
#[\Override]
|
|
public function offsetSet(mixed $offset, mixed $value): void
|
|
{
|
|
$this->store[(string) $offset] = $value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper that builds a strict-variables Twig environment and renders every template
|
|
* with a permissive stub context, capturing any rendering errors.
|
|
*
|
|
* Used by StrictVariablesTest to enforce that all templates are safe to render under
|
|
* `strict_variables => true`. The permissive stub absorbs every undefined variable, attribute,
|
|
* method, and array key, so what remains is structural errors: syntax errors, missing macros,
|
|
* missing parents/blocks, undefined filters/functions, and runtime errors raised by filters.
|
|
*/
|
|
final class StrictTemplateRenderer
|
|
{
|
|
private function suppressNoticesAndWarnings(int $reportingLevel): int
|
|
{
|
|
return $reportingLevel & ~E_NOTICE & ~E_WARNING;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $contextOverrides
|
|
*/
|
|
public function renderTemplate(string $templatePath, array $contextOverrides = [], bool $emailMode = false): string
|
|
{
|
|
$previousLevel = error_reporting();
|
|
error_reporting($this->suppressNoticesAndWarnings($previousLevel));
|
|
|
|
try {
|
|
$twig = $this->buildEnvironment($emailMode, stringifyUrls: true);
|
|
$context = array_replace($this->buildContext($emailMode), $contextOverrides);
|
|
|
|
try {
|
|
$context = $this->enrichContextWithTemplateVariables($twig, $templatePath, $context, $emailMode);
|
|
$context = array_replace($context, $contextOverrides);
|
|
} catch (\Throwable) {
|
|
// Let the render call surface any meaningful parser/loader error.
|
|
}
|
|
|
|
return $twig->render($this->relativeTemplateName($templatePath), $context);
|
|
} finally {
|
|
error_reporting($previousLevel);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<array{file: string, template: string, error: string, category: string}>
|
|
*/
|
|
public function renderAllTemplates(bool $emailMode = false): array
|
|
{
|
|
$findings = [];
|
|
|
|
// Suppress E_NOTICE/E_WARNING only for the duration of this harness.
|
|
// The render-everything pass routinely coerces a PermissiveStub to a
|
|
// scalar (e.g. `|format_currency` on a stub amount, or arithmetic on a
|
|
// stub count), which raises PHP notices that aren't bugs in the
|
|
// templates themselves. Restoring the previous level on the way out
|
|
// keeps unrelated tests in the suite under their normal reporting
|
|
// settings.
|
|
$previousLevel = error_reporting();
|
|
error_reporting($this->suppressNoticesAndWarnings($previousLevel));
|
|
|
|
try {
|
|
$twig = $this->buildEnvironment($emailMode);
|
|
|
|
$templates = $emailMode ? $this->discoverEmailTemplates() : $this->discoverAllTemplates();
|
|
|
|
foreach ($templates as $templatePath) {
|
|
$relative = $this->relativeTemplateName($templatePath);
|
|
$context = $this->buildContext($emailMode);
|
|
|
|
// Pre-populate the context with stubs for every variable referenced in
|
|
// the template. This lets partials (which expect parent-passed
|
|
// variables) render successfully in isolation.
|
|
try {
|
|
$context = $this->enrichContextWithTemplateVariables($twig, $templatePath, $context, $emailMode);
|
|
} catch (\Throwable) {
|
|
// If we can't parse the template, fall through to render and let
|
|
// the loader/parser surface a meaningful error.
|
|
}
|
|
|
|
try {
|
|
$twig->render($relative, $context);
|
|
} catch (\Throwable $e) {
|
|
$findings[] = [
|
|
'file' => $templatePath,
|
|
'template' => $relative,
|
|
'error' => $this->summarizeError($e),
|
|
'category' => $this->categorizeError($e),
|
|
];
|
|
}
|
|
}
|
|
} finally {
|
|
error_reporting($previousLevel);
|
|
}
|
|
|
|
return $findings;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function enrichContextWithTemplateVariables(Environment $twig, string $templatePath, array $context, bool $emailMode): array
|
|
{
|
|
$source = new \Twig\Source((string) file_get_contents($templatePath), $this->relativeTemplateName($templatePath));
|
|
|
|
try {
|
|
$stream = $twig->tokenize($source);
|
|
$nodes = $twig->parse($stream);
|
|
} catch (\Throwable) {
|
|
return $context;
|
|
}
|
|
|
|
$visitor = new VariableCollectorVisitor();
|
|
$traverser = new NodeTraverser($twig, [$visitor]);
|
|
$traverser->traverse($nodes);
|
|
|
|
$stub = new PermissiveStub();
|
|
foreach ($visitor->getVariableNames() as $name) {
|
|
// Don't overwrite globals already provided in the context.
|
|
if (!array_key_exists($name, $context)) {
|
|
$context[$name] = $stub;
|
|
}
|
|
}
|
|
|
|
return $context;
|
|
}
|
|
|
|
private function buildEnvironment(bool $emailMode, bool $stringifyUrls = false): Environment
|
|
{
|
|
$loader = new CombinedTwigLoader(PATH_THEMES);
|
|
// Also register every module template directory under the unnamed path
|
|
// so unqualified `{% include 'mod_foo.html.twig' %}` calls resolve.
|
|
// CombinedTwigLoader already registers them under `@Module_area`
|
|
// namespaces; this mirrors that for bare-name lookups.
|
|
$finder = new Finder();
|
|
$finder->directories()->in(PATH_MODS)->depth('== 2')->ignoreDotFiles(true)->name(['admin', 'client', 'email']);
|
|
foreach ($finder as $dir) {
|
|
$loader->addPath($dir->getPathName());
|
|
}
|
|
$twig = new Environment($loader, [
|
|
'strict_variables' => true,
|
|
'auto_reload' => true,
|
|
'cache' => false,
|
|
'debug' => false,
|
|
]);
|
|
|
|
$twig->getExtension(CoreExtension::class)->setNumberFormat(2, '.', '');
|
|
$twig->getExtension(CoreExtension::class)->setTimezone('UTC');
|
|
|
|
$dateFormatter = new \IntlDateFormatter('en', \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, 'UTC');
|
|
$twig->addExtension(new IntlExtension($dateFormatter));
|
|
$twig->addExtension(new MarkdownExtension());
|
|
|
|
$twig->addExtension(new AttributeExtension(ApiExtension::class));
|
|
$twig->addExtension(new AttributeExtension(FOSSBillingExtension::class));
|
|
$twig->addExtension(new AttributeExtension(LegacyExtension::class));
|
|
|
|
// Stub the debug bar functions so admin layout templates that reference
|
|
// `{{ debug_bar_render_head() }}` and `{{ debug_bar_render() }}` parse
|
|
// and render in the test environment.
|
|
$twig->addFunction(new \Twig\TwigFunction('debug_bar_render_head', fn (): string => ''));
|
|
$twig->addFunction(new \Twig\TwigFunction('debug_bar_render', fn (): string => ''));
|
|
|
|
// Runtime loader: provides stub markdown + FOSSBilling/Legacy/Api
|
|
// extension instances so filter/function method dispatch works in the
|
|
// test environment. A PermissiveContainer absorbs every `$di['x']`
|
|
// access so the extensions can render without a live DI graph.
|
|
$di = $stringifyUrls ? $this->buildUrlAwareContainer() : new PermissiveContainer();
|
|
$twig->addRuntimeLoader(new readonly class($di) implements \Twig\RuntimeLoader\RuntimeLoaderInterface {
|
|
public function __construct(private \Pimple\Container $di)
|
|
{
|
|
}
|
|
|
|
public function load($class)
|
|
{
|
|
if ($class === MarkdownRuntime::class) {
|
|
return new MarkdownRuntime(new class implements MarkdownInterface {
|
|
public function convert(string $body): string
|
|
{
|
|
return $body;
|
|
}
|
|
});
|
|
}
|
|
|
|
if ($class === FOSSBillingExtension::class) {
|
|
return new FOSSBillingExtension($this->di);
|
|
}
|
|
|
|
if ($class === LegacyExtension::class) {
|
|
return new LegacyExtension($this->di);
|
|
}
|
|
|
|
if ($class === ApiExtension::class) {
|
|
return new ApiExtension($this->di);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
$context = $this->buildContext($emailMode);
|
|
foreach ($context as $name => $value) {
|
|
$twig->addGlobal($name, $value);
|
|
}
|
|
|
|
return $twig;
|
|
}
|
|
|
|
private function buildUrlAwareContainer(): \Pimple\Container
|
|
{
|
|
return new UrlAwarePermissiveContainer();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildContext(bool $emailMode): array
|
|
{
|
|
$stub = new PermissiveStub();
|
|
|
|
if ($emailMode) {
|
|
// Email templates have a different `guest` shape with precomputed globals.
|
|
return [
|
|
'request' => $stub,
|
|
'request_query' => $stub,
|
|
'request_path' => '/',
|
|
'request_has_filters' => false,
|
|
'CSRFToken' => 'test',
|
|
'FOSSBillingVersion' => '0.0.0',
|
|
'default_currency' => 'USD',
|
|
'app_area' => 'email',
|
|
'current_theme' => 'admin_default',
|
|
'theme' => ['code' => 'admin_default', 'name' => 'admin_default', 'url' => '/themes/admin_default/'],
|
|
'settings' => $stub,
|
|
'guest' => [
|
|
'system_company' => [
|
|
'name' => 'Test Co',
|
|
'signature' => '',
|
|
'favicon_url' => '',
|
|
],
|
|
'system_email' => [
|
|
'signature' => '',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'request' => $stub,
|
|
'request_query' => $stub,
|
|
'request_path' => '/',
|
|
'request_has_filters' => false,
|
|
'CSRFToken' => 'test',
|
|
'FOSSBillingVersion' => '0.0.0',
|
|
'default_currency' => 'USD',
|
|
'app_area' => 'admin',
|
|
'current_theme' => 'admin_default',
|
|
'theme' => ['code' => 'admin_default', 'name' => 'admin_default', 'url' => '/themes/admin_default/'],
|
|
'settings' => $stub,
|
|
'admin' => $stub,
|
|
'client' => $stub,
|
|
'guest' => $stub,
|
|
'mf' => $stub, // Most templates import this; let missing macros surface as errors
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function discoverAllTemplates(): array
|
|
{
|
|
$paths = [];
|
|
|
|
// Module templates
|
|
$finder = new Finder();
|
|
$finder->files()->in(PATH_MODS)->name('*.html.twig');
|
|
foreach ($finder as $file) {
|
|
$paths[] = $file->getPathName();
|
|
}
|
|
|
|
// Theme templates
|
|
$finder = new Finder();
|
|
$finder->files()->in(PATH_THEMES)->name('*.html.twig');
|
|
foreach ($finder as $file) {
|
|
$paths[] = $file->getPathName();
|
|
}
|
|
|
|
sort($paths);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function discoverEmailTemplates(): array
|
|
{
|
|
$paths = [];
|
|
$finder = new Finder();
|
|
$finder->files()->in(PATH_MODS)->path('templates/email')->name('*.html.twig');
|
|
foreach ($finder as $file) {
|
|
$paths[] = $file->getPathName();
|
|
}
|
|
sort($paths);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
private function relativeTemplateName(string $absolutePath): string
|
|
{
|
|
// Module templates live under `modules/<Module>/templates/<area>/` and
|
|
// are referenced as `@<Module>_<area>/<basename>` (underscore join
|
|
// because FilesystemLoader splits `@ns/template` on the first '/' and
|
|
// rejects '/' in namespace names). Theme templates live under
|
|
// `themes/<code>/html/` and are referenced by basename.
|
|
if (str_starts_with($absolutePath, PATH_MODS . DIRECTORY_SEPARATOR)) {
|
|
$relative = substr($absolutePath, strlen(PATH_MODS . DIRECTORY_SEPARATOR));
|
|
$parts = explode(DIRECTORY_SEPARATOR, $relative, 4);
|
|
if (count($parts) === 4) {
|
|
$module = $parts[0];
|
|
$area = $parts[2]; // skip 'templates' dir
|
|
|
|
return '@' . $module . '_' . $area . '/' . basename($parts[3]);
|
|
}
|
|
}
|
|
|
|
return basename($absolutePath);
|
|
}
|
|
|
|
private const int MAX_ERROR_MESSAGE_LENGTH = 250;
|
|
|
|
private function summarizeError(\Throwable $e): string
|
|
{
|
|
$message = $e->getMessage();
|
|
if (strlen($message) > self::MAX_ERROR_MESSAGE_LENGTH) {
|
|
$message = substr($message, 0, self::MAX_ERROR_MESSAGE_LENGTH) . '...';
|
|
}
|
|
|
|
return $e::class . ': ' . $message;
|
|
}
|
|
|
|
/**
|
|
* Classify a render error as either a real template bug or a test
|
|
* infrastructure issue. The strict-variables test fails only on
|
|
* `real-bug` findings; `test-infra` and `loader` issues are informational
|
|
* because the permissive container/stub setup cannot satisfy strict
|
|
* return-type hints or lookup every real template path.
|
|
*/
|
|
private function categorizeError(\Throwable $e): string
|
|
{
|
|
$message = $e->getMessage();
|
|
|
|
if ($e instanceof \Twig\Error\LoaderError) {
|
|
// LoaderErrors during render are usually caused by child templates
|
|
// (e.g. `partial_embed_styles.html.twig`) being looked up in the
|
|
// current namespace rather than the main one. This is a quirk of
|
|
// the render-everything harness, not a real template bug.
|
|
return 'test-infra';
|
|
}
|
|
|
|
if ($e instanceof \Twig\Error\SyntaxError) {
|
|
return 'real-bug';
|
|
}
|
|
|
|
if (str_contains($message, 'Variable ') && str_contains($message, ' does not exist')) {
|
|
return 'real-bug';
|
|
}
|
|
|
|
if (str_contains($message, 'Key "') && str_contains($message, ' does not exist')) {
|
|
return 'real-bug';
|
|
}
|
|
|
|
// Test infrastructure issues: type mismatches caused by PermissiveStub
|
|
// being returned where a typed return is expected (string, array, etc.),
|
|
// and method-on-null errors caused by stub methods that don't match real
|
|
// service signatures.
|
|
if (str_contains($message, 'Return value must be of type')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'on null')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'Unable to load')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'must be of type float') || str_contains($message, 'must be of type int')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'Unsupported operand types') && str_contains($message, PermissiveStub::class)) {
|
|
// Templates that do arithmetic directly on two attribute lookups
|
|
// (e.g. `order.total - order.discount`) hit this when both sides
|
|
// resolve to PermissiveStub instead of a real number. PHP objects
|
|
// don't support arithmetic operators, so this is a stub
|
|
// limitation, not a template bug. The class-name check keeps a
|
|
// genuine "Unsupported operand types" bug elsewhere in a
|
|
// template (unrelated to the stub) classified as real-bug.
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'NumberFormatter::formatCurrency')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'is not callable')) {
|
|
return 'test-infra';
|
|
}
|
|
if (str_contains($message, 'is not iterable')) {
|
|
return 'test-infra';
|
|
}
|
|
|
|
return 'real-bug';
|
|
}
|
|
}
|