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>
This commit is contained in:
2026-09-26 17:53:54 +02:00
co-authored by Claude Opus 5.5
parent 3d96a41366
commit 72eeaa46ad
34 changed files with 725 additions and 353 deletions
+181
View File
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Support;
use LIT\MCP\ChangeLog\ChangeLog;
use LogicException;
use PHPUnit\Framework\Assert;
use Throwable;
/**
* ChangeLog without a database. Allowed calls are kept in $records / $partials (arguments by
* name); record() returns increasing ids from $firstId, or throws $failure after noting the call.
*
* What a fake allows mirrors the Mockery mocks it replaces:
* - new FakeChangeLog(id) record() only (allows('record')->andReturn(id))
* - FakeChangeLog::partialsOnly() recordPartial() only (expects('recordPartial'))
* - FakeChangeLog::any(id) both
* - FakeChangeLog::forbidden() nothing (a mock without expectations)
* A call that is not allowed throws – and, because the code under test often swallows
* change-log failures (FollowUp), it is also noted and fails the test in TestCase::tearDown()
* via verify().
*/
class FakeChangeLog extends ChangeLog
{
/**
* @var list<array{ability: string, object_type: string, object_id: string, summary: string,
* revision_id: ?int, snapshot_id: ?string, result: string}>
*/
public array $records = [];
/**
* @var list<array{ability: string, object_type: string, object_id: string, summary: string,
* revision_id: ?int}>
*/
public array $partials = [];
/** @var list<string> Calls that were not allowed, across all fakes of the running test. */
private static array $unexpected = [];
private static bool $used = false;
public function __construct(
private int $firstId = 1,
private ?Throwable $failure = null,
private bool $allowsRecord = true,
private bool $allowsPartial = false
) {
parent::__construct();
self::$used = true;
}
public static function forbidden(): self
{
return new self(1, null, false, false);
}
public static function partialsOnly(): self
{
return new self(1, null, false, true);
}
public static function any(int $firstId = 1): self
{
return new self($firstId, null, true, true);
}
/**
* Fails the running test if any fake got a call it does not allow; resets for the next test.
* Counts as one assertion in tests that created a fake (like a Mockery never() expectation).
*/
public static function verify(): void
{
$unexpected = self::$unexpected;
$used = self::$used;
self::$unexpected = [];
self::$used = false;
if ($used || $unexpected !== []) {
Assert::assertSame([], $unexpected, 'Unexpected change log calls: ' . implode('; ', $unexpected));
}
}
public function record(
string $ability,
string $objectType,
string $objectId,
string $summary,
?int $revisionId = null,
?string $snapshotId = null,
string $result = 'ok'
): ?int {
$this->refuseUnless($this->allowsRecord, sprintf('record(%s, %s, %s)', $ability, $objectType, $objectId));
$this->records[] = [
'ability' => $ability,
'object_type' => $objectType,
'object_id' => $objectId,
'summary' => $summary,
'revision_id' => $revisionId,
'snapshot_id' => $snapshotId,
'result' => $result,
];
if ($this->failure !== null) {
throw $this->failure;
}
return $this->firstId + count($this->records) - 1;
}
public function recordPartial(
string $ability,
string $objectType,
string $objectId,
string $summary,
?int $revisionId = null
): void {
$this->refuseUnless(
$this->allowsPartial,
sprintf('recordPartial(%s, %s, %s)', $ability, $objectType, $objectId)
);
$this->partials[] = [
'ability' => $ability,
'object_type' => $objectType,
'object_id' => $objectId,
'summary' => $summary,
'revision_id' => $revisionId,
];
}
/**
* Asserts the record() calls: one $expected per call, in order; each names only the
* arguments it checks (ability, object_type, object_id, summary, revision_id, …).
*
* @param array<string, mixed> ...$expected
*/
public function assertRecorded(array ...$expected): void
{
self::assertCalls('record()', $expected, $this->records);
}
/**
* Asserts the recordPartial() calls like assertRecorded().
*
* @param array<string, mixed> ...$expected
*/
public function assertPartials(array ...$expected): void
{
self::assertCalls('recordPartial()', $expected, $this->partials);
}
/**
* @param list<array<string, mixed>> $expected
* @param list<array<string, mixed>> $actual
*/
private static function assertCalls(string $method, array $expected, array $actual): void
{
Assert::assertCount(count($expected), $actual, 'Number of ChangeLog::' . $method . ' calls');
foreach ($expected as $i => $call) {
$checked = [];
foreach (array_keys($call) as $key) {
Assert::assertArrayHasKey($key, $actual[$i], 'Unknown ChangeLog argument');
$checked[$key] = $actual[$i][$key];
}
Assert::assertSame($call, $checked, 'ChangeLog::' . $method . ' call ' . $i);
}
}
/** @return list<string> The summaries of record(), in order. */
public function summaries(): array
{
return array_column($this->records, 'summary');
}
private function refuseUnless(bool $allowed, string $call): void
{
if (! $allowed) {
self::$unexpected[] = $call;
throw new LogicException('Unexpected ChangeLog::' . $call);
}
}
}