justReturn('2026-09-25 08:00:00'); Functions\when('get_current_user_id')->justReturn(3); RequestContext::reset(true, 'Claude'); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('insert') ->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool { return $row['created_at'] === '2026-09-25 08:00:00' && $row['user_id'] === 3 && $row['client'] === 'Claude' && $row['ability'] === 'lit/content-update' && $row['object_type'] === 'page' && $row['object_id'] === '12' && $row['summary'] === 'Seite geändert' && $row['revision_id'] === 811 && $row['snapshot_id'] === null && $row['result'] === 'ok'; }), Mockery::type('array')) ->andReturnUsing(static function () use ($db): int { $db->insert_id = 4711; return 1; }); $id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'Seite geändert', 811); self::assertSame(4711, $id); } public function testRecordReturnsNullWhenTheInsertFails(): void { Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); Functions\when('get_current_user_id')->justReturn(3); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('insert')->andReturn(false); $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $previous = ini_set('error_log', $log); try { $id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'x'); } finally { ini_set('error_log', (string) $previous); } self::assertNull($id); self::assertStringContainsString('change log insert failed', (string) file_get_contents($log)); unlink($log); } private function dbInserting(int|false $outcome): \wpdb { Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); Functions\when('get_current_user_id')->justReturn(3); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('insert')->andReturnUsing(static function () use ($db, $outcome): int|false { $db->insert_id = 77; return $outcome; }); return $db; } public function testAnOkEntryAnnouncesTheChange(): void { $seen = []; Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { $seen = $args; }); (new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x'); self::assertSame(['lit/content-update', 'page', '12', 77], $seen); } public function testARefusedEntryDoesNotAnnounceAChange(): void { Actions\expectDone('lit_mcp_changed')->never(); (new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'refused'); $this->addToAssertionCount(1); } /** A partly executed change changed something: listeners (cache purge) must hear of it. */ public function testAPartialEntryAnnouncesTheChange(): void { $seen = []; Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { $seen = $args; }); (new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'partial'); self::assertSame(['lit/x', 'page', '1', 77], $seen); } /** Listeners get the same shortened values as the log row (ability 100, type 50, id 191). */ public function testTheAnnouncementCarriesTheShortenedValuesOfTheRow(): void { $seen = []; Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { $seen = $args; }); (new ChangeLog($this->dbInserting(1))) ->record(str_repeat('a', 150), str_repeat('t', 80), str_repeat('ä', 300), 'x'); self::assertSame([str_repeat('a', 100), str_repeat('t', 50), str_repeat('ä', 191), 77], $seen); } /** The write happened even when its log row did not: listeners (cache purge) still need to know. */ public function testAFailedInsertStillAnnouncesTheChangeWithoutALogId(): void { $seen = []; Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { $seen = $args; }); $previous = ini_set('error_log', (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log')); try { $id = (new ChangeLog($this->dbInserting(false)))->record('lit/options', 'option', 'blogname', 'x'); } finally { ini_set('error_log', (string) $previous); } self::assertNull($id); self::assertSame(['lit/options', 'option', 'blogname', null], $seen); } public function testAThrowingListenerChangesNothingAndIsLogged(): void { Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (): void { throw new \RuntimeException('listener broke'); }); $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $previous = ini_set('error_log', $log); try { $id = (new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x'); } finally { ini_set('error_log', (string) $previous); } self::assertSame(77, $id); self::assertStringContainsString('lit_mcp_changed failed', (string) file_get_contents($log)); self::assertStringContainsString('listener broke', (string) file_get_contents($log)); unlink($log); } public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void { Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); Functions\when('get_current_user_id')->justReturn(3); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('insert') ->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool { return $row['object_id'] === str_repeat('ä', 191) && $row['ability'] === str_repeat('ö', 100); }), Mockery::type('array')) ->andReturn(1); (new ChangeLog($db))->record(str_repeat('ö', 150), 'option', str_repeat('ä', 300), 'x'); } public function testRecordPartialStoresResultPartial(): void { Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); Functions\when('get_current_user_id')->justReturn(3); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('insert') ->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool { return $row['result'] === 'partial' && $row['revision_id'] === 7 && $row['object_id'] === '12'; }), Mockery::type('array')) ->andReturn(1); (new ChangeLog($db))->recordPartial('lit/content-update', 'page', '12', 'teilweise', 7); } public function testRecordPartialNeverThrows(): void { Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); Functions\when('get_current_user_id')->justReturn(3); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('insert')->andThrow(new \RuntimeException('db gone')); $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $previous = ini_set('error_log', $log); try { (new ChangeLog($db))->recordPartial('lit/menu-set-items', 'nav_menu', '4', 'teilweise'); } finally { ini_set('error_log', (string) $previous); } self::assertStringContainsString('db gone', (string) file_get_contents($log)); unlink($log); } public function testPurgeDeletesRowsOlderThanTheRetention(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('query') ->with(Mockery::on(static fn (string $sql): bool => str_starts_with( $sql, "DELETE FROM wp_lit_mcp_log WHERE created_at < '" ))) ->andReturn(3); self::assertSame(3, (new ChangeLog($db))->purgeOlderThan(90)); } public function testQueryFiltersAndPaginatesNewestFirst(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('get_var') ->with(Mockery::on(static fn (string $sql): bool => $sql === "SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1 AND ability = 'lit/content-update'" . " AND user_id = 3")) ->andReturn('12'); $db->expects('get_results') ->with( Mockery::on(static fn (string $sql): bool => str_ends_with($sql, 'ORDER BY id DESC LIMIT 50 OFFSET 50')), 'ARRAY_A' ) ->andReturn([['id' => '7', 'ability' => 'lit/content-update']]); $result = (new ChangeLog($db))->query(['ability' => 'lit/content-update', 'user_id' => 3], 50, 50); self::assertSame(12, $result['total']); self::assertSame('7', $result['rows'][0]['id']); } public function testQueryFiltersByResultAndDateRange(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('get_var') ->with(Mockery::on(static fn (string $sql): bool => $sql === "SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1 AND result = 'partial'" . " AND created_at >= '2026-09-01 00:00:00' AND created_at <= '2026-09-25 23:59:59'")) ->andReturn('2'); $db->expects('get_results')->andReturn([]); $result = (new ChangeLog($db))->query( ['result' => 'partial', 'date_from' => '2026-09-01', 'date_to' => '2026-09-25'], 50, 0 ); self::assertSame(2, $result['total']); self::assertSame([], $result['rows']); } public function testQueryWithoutFiltersUsesTheBaseCondition(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('get_var') ->with('SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1') ->andReturn('0'); $db->expects('get_results')->andReturn(null); $result = (new ChangeLog($db))->query([], 50, 0); self::assertSame(0, $result['total']); self::assertSame([], $result['rows']); } public function testAbilitiesCountAndClear(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $db->expects('get_col')->with('SELECT DISTINCT ability FROM wp_lit_mcp_log ORDER BY ability')->andReturn(['lit/a']); $db->expects('get_var')->with('SELECT COUNT(*) FROM wp_lit_mcp_log')->andReturn('7'); $db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4); $log = new ChangeLog($db); self::assertSame(['lit/a'], $log->abilities()); self::assertSame(7, $log->count()); $log->clear(); } }