Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php
T
janniclas.loosenandClaude Opus 5.5 60290c85a5 test: AssertsToolErrors helper for tool error assertions (T3)
tests/Support/AssertsToolErrors (used by TestCase): assertToolError($result, code)
checks the WP_Error code lit_<code> and the code of the client JSON and returns the
payload; clientError() decodes the payload; errorFields() returns the fields and
asserts they exist. Replaces the private clientError() decoders of
DiviWriteAbilitiesTest/DiviReplaceTextAbilityTest, 216 single-line
assertSame('lit_…', …->get_error_code()) assertions, 24 get_error_data()['fields']
digs and two json_decode(message) reads. ErrorsTest keeps json_decode: it tests
the format itself. Tests 1404 -> 1409 (AssertsToolErrorsTest); no test has fewer
assertions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 17:55:35 +02:00

288 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\Integrations\WordPress\Settings\SettingsWhitelist;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility;
use LIT\MCP\Tests\Support\FakeChangeLog;
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 = new FakeChangeLog(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 = new FakeChangeLog(1, 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']);
$log->assertRecorded([]);
}
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(), FakeChangeLog::forbidden()))
->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]);
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('admin_email', self::errorFields($result));
}
public function testRejectsAGenuinelyUnknownKey(): void
{
Functions\expect('update_option')->never();
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))
->execute(['settings' => ['template' => 'twentyten']]);
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('template', self::errorFields($result));
}
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 = new FakeChangeLog(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']);
$log->assertRecorded([
'ability' => 'lit/wp-settings-update',
'object_type' => 'option',
'object_id' => 'blogname,permalink_structure',
]);
}
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 = new FakeChangeLog(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']);
$log->assertRecorded([]);
}
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 = FakeChangeLog::forbidden();
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
'blogname' => 'Neu',
'blogdescription' => 'Auch neu',
]);
self::assertToolError($error, 'write_failed');
$log->assertRecorded();
$log->assertPartials();
}
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 = FakeChangeLog::partialsOnly();
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
'blogname' => 'Neu',
'blogdescription' => 'Auch neu',
]);
self::assertToolError($error, 'partial_write');
self::assertStringContainsString('blogname', $error->get_error_message());
self::assertStringContainsString('lit/wp-settings-get', $error->get_error_data()['hint']);
$log->assertPartials([
'ability' => 'lit/wp-settings-update',
'object_type' => 'option',
'object_id' => 'blogname',
]);
}
/** @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::assertToolError((new WpSettingsGetAbility(new SettingsWhitelist()))->permission([]), 'forbidden');
self::assertSame(
'lit_forbidden',
(new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->permission([])->get_error_code()
);
}
}