Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php
T
janniclas.loosenandClaude Opus 5.5 ff0da9f406 fix: settings compare against the sanitized value, fail via WriteFailed
- isStored() compares the stored option with sanitize_option() of the new value
  (core esc_html()s blogname/blogdescription): setting "Müller & Söhne" again
  no longer looks like a failed write; lit/wp-settings-get and previous return
  those two decoded, as the user would type them.
- The change log runs as FollowUp (warning on failure); a failed write throws
  WriteFailed (write_failed / partial_write with keys), partial writes are
  logged with result partial.
- permalink_structure needs a real %tag% (WordPress' own /%[^/%]+%/ rule).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 01:26:11 +02:00

282 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;
final class WpSettingsAbilitiesTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Like core: blogname/blogdescription are stored esc_html()'d, the rest as given here.
Functions\when('sanitize_option')->alias(static fn (string $key, $value) => in_array($key, ['blogname', 'blogdescription'], true)
? htmlspecialchars((string) $value, ENT_QUOTES)
: $value);
Functions\when('wp_specialchars_decode')->alias(static fn (string $text, $quotes = ENT_NOQUOTES): string => htmlspecialchars_decode($text, ENT_QUOTES));
}
/**
* A stateful option store: update_option() sanitizes like core and returns false when
* nothing changes.
*
* @param array<string, mixed> $options
*/
private function optionStore(array &$options): void
{
Functions\when('get_option')->alias(static function (string $key) use (&$options) {
return $options[$key];
});
Functions\when('update_option')->alias(static function (string $key, $value) use (&$options): bool {
$value = in_array($key, ['blogname', 'blogdescription'], true) ? htmlspecialchars((string) $value, ENT_QUOTES) : $value;
if ($options[$key] === $value) {
return false;
}
$options[$key] = $value;
return true;
});
}
public function testTextWithAnAmpersandCanBeSetTwiceAlongsideAnotherKey(): void
{
$options = ['blogname' => 'Alt', 'blogdescription' => 'Praxis'];
$this->optionStore($options);
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(40);
$ability = new WpSettingsUpdateAbility(new SettingsWhitelist(), $log);
$input = ['settings' => ['blogname' => 'Müller & Söhne', 'blogdescription' => 'Zahnärzte']];
$first = $ability->execute($input);
$second = $ability->execute($input);
self::assertSame('Müller &amp; Söhne', $options['blogname']);
self::assertSame(['blogname', 'blogdescription'], array_column($first['changed'], 'id'));
self::assertTrue($second['ok']);
self::assertSame([], $second['changed']);
self::assertSame(['blogname' => 'Müller & Söhne', 'blogdescription' => 'Zahnärzte'], $second['previous']);
}
public function testGetReturnsTextAsTheUserWouldTypeIt(): void
{
Functions\when('get_option')->alias(static fn (string $key) => $key === 'blogname' ? 'Müller &amp; Söhne &quot;A&quot;' : '1');
$settings = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'general'])['settings'];
self::assertSame('Müller & Söhne "A"', $settings[0]['value']);
}
public function testAFailingChangeLogIsOnlyAWarning(): void
{
$options = ['blogname' => 'Alt'];
$this->optionStore($options);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andThrow(new RuntimeException('log kaputt'));
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $file);
try {
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => ['blogname' => 'Neu']]);
} finally {
ini_set('error_log', (string) $previous);
unlink($file);
}
self::assertTrue($result['ok']);
self::assertNull($result['log_id']);
self::assertCount(1, $result['warnings']);
}
protected function tearDown(): void
{
unset($GLOBALS['wp_rewrite']);
parent::tearDown();
}
public function testGetFiltersByGroup(): void
{
Functions\when('get_option')->justReturn('Kunde');
$result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']);
self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key'));
self::assertSame('discussion', $result['settings'][0]['group']);
}
public function testUpdateIsAllOrNothing(): void
{
Functions\expect('update_option')->never();
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))
->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']);
}
public function testRejectsAGenuinelyUnknownKey(): void
{
Functions\expect('update_option')->never();
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))
->execute(['settings' => ['template' => 'twentyten']]);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertArrayHasKey('template', $result->get_error_data()['fields']);
}
public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void
{
// A mutating stub: writeOne() verifies a write via get_option() afterwards, so
// the stub must actually reflect what update_option()/set_permalink_structure()
// "wrote", not just return a fixed value.
$options = [
'blogname' => 'Alt',
'blogdescription' => 'Gleich',
'permalink_structure' => '',
];
Functions\when('get_option')->alias(static function (string $key) use (&$options) {
return $options[$key];
});
Functions\expect('update_option')->once()->with('blogname', 'Neu')
->andReturnUsing(static function (string $key, $value) use (&$options): bool {
$options[$key] = $value;
return true;
});
$rewrite = Mockery::mock();
$rewrite->expects('set_permalink_structure')->with('/%postname%/')
->andReturnUsing(static function (string $structure) use (&$options): void {
$options['permalink_structure'] = $structure;
});
$GLOBALS['wp_rewrite'] = $rewrite;
Functions\expect('flush_rewrite_rules')->once();
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/wp-settings-update', 'option', 'blogname,permalink_structure', Mockery::type('string'))->andReturn(30);
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
'blogname' => 'Neu',
'blogdescription' => 'Gleich',
'permalink_structure' => '/%postname%/',
]]);
self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id'));
self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']);
self::assertSame(30, $result['log_id']);
}
public function testDoesNotTreatAnAlreadyStoredValueAsAWriteFailure(): void
{
// update_option() returns false both for a genuine failure and for a value that
// already matches the stored option. WPLANG's public value "en_US" normalizes to
// WordPress' internal "" – if the "already set?" check compared the raw client
// input against SettingsWhitelist::current()'s aliased public form instead of the
// normalized value against the actual stored option, this looked like a change,
// update_option() returned false because nothing needed to change, and that false
// was wrongly read as a failure – rolling back the unrelated blogname change too.
Functions\when('get_option')->alias(static fn (string $key) => [
'blogname' => 'Alt',
'WPLANG' => '',
][$key]);
Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andReturn(31);
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
'blogname' => 'Neu',
'WPLANG' => 'en_US',
]]);
self::assertSame(['blogname'], array_column($result['changed'], 'id'));
self::assertSame(31, $result['log_id']);
}
public function testRestoresPreviousValuesWhenAWriteFailsPartway(): void
{
Functions\when('get_option')->alias(static fn (string $key) => [
'blogname' => 'Alt',
'blogdescription' => 'Vorher',
][$key]);
Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true);
Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false);
Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(true);
$log = Mockery::mock(ChangeLog::class);
$log->shouldNotReceive('record');
$log->shouldNotReceive('recordPartial');
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
'blogname' => 'Neu',
'blogdescription' => 'Auch neu',
]);
self::assertSame('lit_write_failed', $error->get_error_code());
}
public function testReportsPartialWriteWhenRestoringAlsoFails(): void
{
// Stateful: the restore write must genuinely not take effect (stay "Neu") for
// isStored() to correctly report the rollback as incomplete.
$options = [
'blogname' => 'Alt',
'blogdescription' => 'Vorher',
];
Functions\when('get_option')->alias(static function (string $key) use (&$options) {
return $options[$key];
});
Functions\expect('update_option')->once()->with('blogname', 'Neu')
->andReturnUsing(static function (string $key, $value) use (&$options): bool {
$options[$key] = $value;
return true;
});
Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false);
Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(false);
$log = Mockery::mock(ChangeLog::class);
$log->shouldNotReceive('record');
$log->expects('recordPartial')->with('lit/wp-settings-update', 'option', 'blogname', Mockery::type('string'));
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
'blogname' => 'Neu',
'blogdescription' => 'Auch neu',
]);
self::assertSame('lit_partial_write', $error->get_error_code());
self::assertStringContainsString('blogname', $error->get_error_message());
self::assertStringContainsString('lit/wp-settings-get', $error->get_error_data()['hint']);
}
/** @param array<string, mixed> $settings */
private function failure(WpSettingsUpdateAbility $ability, array $settings): WP_Error
{
try {
$ability->execute(['settings' => $settings]);
} catch (WriteFailed $e) {
return $e->toError();
}
self::fail('WriteFailed expected');
}
public function testSettingsToolsNeedManageOptions(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options');
self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code());
self::assertSame(
'lit_forbidden',
(new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))->permission([])->get_error_code()
);
}
}