Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php
T
janniclas.loosenandClaude Opus 5.5 72eeaa46ad test: FakeChangeLog and SchemaFixture instead of repeated mocks (T2)
- tests/Support/FakeChangeLog extends ChangeLog: records record()/recordPartial()
  calls, increasing ids, optional failure; new FakeChangeLog(id) / partialsOnly() /
  any(id) / forbidden() mirror what the replaced mocks allowed. assertRecorded() /
  assertPartials() check count and the named arguments. Calls a fake does not allow
  throw and are also verified in TestCase::tearDown (one assertion per test that
  uses a fake), so swallowed change-log calls still fail like a never() expectation.
- All ChangeLog mocks of ability/integration tests replaced (the admin tests that
  stub query/purge/clear keep their mocks); expectException tests that checked the
  partial log now catch and assert.
- SchemaFixture::managed(?divi) replaces the 21 isManaged-only ContentSchema mocks;
  DiviFixtures::schema() uses it.
Tests 1397 -> 1404 (FakeChangeLogTest); no test has fewer assertions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 17:53:54 +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::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(), FakeChangeLog::forbidden()))
->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 = 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::assertSame('lit_write_failed', $error->get_error_code());
$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::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']);
$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::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code());
self::assertSame(
'lit_forbidden',
(new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->permission([])->get_error_code()
);
}
}