1
0
Files
breadcrumb-the-shire/tests/Architecture/ActionContextHelperContractTest.php
fs 9ec10f5c02 refactor(action-context): remove unused fragment aggregator + building block
actionFragmentContext was built spec-driven in step 1 to handle the
anticipated drawer-fragment pattern: authorize + finder → status DTO.
The action-context rollout (steps 2-11) found that none of the three
real drawer fragments (users/view-fragment, addressbook/view-fragment,
helpdesk/ticket-fragment) match that pattern — all three delegate to
domain services that own their own status models. Step 11 declared
them structural exceptions; the helper code remained unused.

This commit removes the dead spec:
* core/Support/helpers/action_context.php drops actionFragmentContext
  (~9 LOC) and its building block actionFragmentResolveOrStatus
  (~30 LOC) plus their docblocks. The MUST-call-actionRequireCsrf
  warning, present in three aggregator docblocks before, now appears
  twice (one per remaining aggregator).
* tests/Support/Helpers/ActionContextHelperTest.php drops the six
  unit tests that exercised these functions (~83 LOC).
* tests/Architecture/ActionContextHelperContractTest.php drops the
  fragment building block from the buildingBlocks() data provider
  (5 entries instead of 6) and removes testFragmentResolveReturnDocblockIsFrozen.
  The CSRF-warning expectation is updated from 3 to 2 with a code
  comment explaining the rollback.

Verified:
* No production caller exists in pages/ or modules/.
* All 9 aggregator callers (5 actionEditContext + 4 actionCreateContext)
  remain unchanged.
* ActionContextCsrfPairingContractTest and DetailDrawerFragmentContractTest
  are deliberately left untouched: their allowlists/recognizer regexes
  still mention actionFragmentContext, but the patterns now match an
  empty set — harmless dead text. Documented as open items in the run
  report; future cleanup is optional and orthogonal to this removal.
* QGs all green (PHPUnit 2088 tests, PHPStan level 5, CS-fixer 0 diffs).

Net: 3 files, +31/-198 LOC, behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:34:53 +02:00

205 lines
7.8 KiB
PHP

<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
use ReflectionFunction;
use ReflectionNamedType;
/**
* Freezes the API of the 5 orthogonal building blocks in
* core/Support/helpers/action_context.php.
*
* Aggregators (actionEditContext, actionCreateContext) are intentionally NOT
* frozen — they may evolve additively across the rollout.
*
* This test also asserts that the Tenant-Scope helper carries its PHPStan
* array-shape return docblock. Acceptance check SC-004 relies on the shape
* staying stable.
*
* Step 2 (Pilot-Migration Departments-Edit, Run
* 2026-04-25-action-context-rollout-step2-departments) is the first
* production caller. The previous testNoProductionCallSitesYet assertion
* has been removed; the CSRF↔Aggregator pairing is enforced by
* tests/Architecture/ActionContextCsrfPairingContractTest.php instead.
*
* Run 2026-04-25-action-fragment-context-removal pruned the dead
* Cluster-10 Drawer-Fragment helpers (actionFragmentResolveOrStatus building
* block + actionFragmentContext aggregator) after Step 11 declared all three
* fragment actions structural exceptions that delegate to domain-services
* with their own status models.
*/
class ActionContextHelperContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return array<int, array{0: string, 1: list<array{0: string, 1?: mixed, 2?: bool}>}>
*/
public static function buildingBlocks(): array
{
// [function-name, [[paramName, ?defaultValue, hasDefault], ...]]
return [
['actionResolveModelOrFail', [
['finder', null, false],
['rawId', null, false],
['notFoundFlashKey', null, false],
['redirectPath', null, false],
]],
['actionAuthorizeAndExtractCapabilities', [
['abilityKey', null, false],
['context', null, false],
['forbiddenStrategy', 'redirect', true],
]],
['actionDeriveTenantScope', [
['capabilities', null, false],
['allTenantsFlagKey', 'can_manage_all_tenants', true],
['allowedListKey', 'allowed_tenant_ids', true],
]],
['actionEnforceCanViewPage', [
['capabilities', null, false],
['flagKey', 'can_view_page', true],
['forbiddenStrategy', 'redirect', true],
]],
['actionBuildViewAuth', [
['capabilities', null, false],
['whitelistedFlags', null, false],
]],
];
}
public function testHelperFileExistsAndIsRegistered(): void
{
$this->assertFileExists($this->projectRootPath() . '/core/Support/helpers/action_context.php');
$helpersFile = $this->readProjectFile('core/Support/helpers.php');
$this->assertStringContainsString(
"require __DIR__ . '/helpers/action_context.php';",
$helpersFile,
'helpers/action_context.php must be required from core/Support/helpers.php'
);
}
public function testBuildingBlocksExistWithStableSignatures(): void
{
foreach (self::buildingBlocks() as [$fnName, $expectedParams]) {
$this->assertTrue(
function_exists($fnName),
"Helper function '{$fnName}' must exist (frozen by Step 1)."
);
$reflection = new ReflectionFunction($fnName);
$actualParams = $reflection->getParameters();
$this->assertSameSize(
$expectedParams,
$actualParams,
"Parameter count for '{$fnName}' has changed."
);
foreach ($expectedParams as $i => [$expectedName, $expectedDefault, $hasDefault]) {
$this->assertSame(
$expectedName,
$actualParams[$i]->getName(),
"Parameter #{$i} of '{$fnName}' has been renamed."
);
$this->assertSame(
$hasDefault,
$actualParams[$i]->isDefaultValueAvailable(),
"Parameter '{$expectedName}' of '{$fnName}' default-availability has changed."
);
if ($hasDefault) {
$this->assertSame(
$expectedDefault,
$actualParams[$i]->getDefaultValue(),
"Parameter '{$expectedName}' of '{$fnName}' default has changed."
);
}
}
}
}
public function testTenantScopeReturnDocblockIsFrozen(): void
{
$reflection = new ReflectionFunction('actionDeriveTenantScope');
$doc = (string) $reflection->getDocComment();
$this->assertStringContainsString(
"@return array{scope: 'all'|'list', ids: list<int>}",
$doc,
"actionDeriveTenantScope must declare PHPStan array-shape return for GR-SEC-009."
);
}
public function testEnforceCanViewPageDocblockHasStrictComparisonNote(): void
{
$reflection = new ReflectionFunction('actionEnforceCanViewPage');
$doc = (string) $reflection->getDocComment();
$this->assertStringContainsString(
'Strict comparison (=== true)',
$doc,
"actionEnforceCanViewPage docblock must document strict-comparison contract."
);
$this->assertStringContainsString(
'No truthy coercion',
$doc,
"actionEnforceCanViewPage docblock must document no-truthy-coercion contract."
);
}
public function testBuildViewAuthDocblockHasEscapeWarning(): void
{
$reflection = new ReflectionFunction('actionBuildViewAuth');
$doc = (string) $reflection->getDocComment();
$this->assertStringContainsString(
'MUST e()-escape',
$doc,
"actionBuildViewAuth docblock must require e()-escaping per GR-SEC-010."
);
}
public function testAggregatorDocblocksWarnAboutCsrf(): void
{
$contents = $this->readProjectFile('core/Support/helpers/action_context.php');
$count = preg_match_all(
'/MUST call actionRequireCsrf\(\) BEFORE this aggregator/',
$contents
);
// Expected count is 2: actionEditContext + actionCreateContext.
// Run 2026-04-25-action-fragment-context-removal removed the
// actionFragmentContext aggregator (Cluster 10 has no aggregator —
// drawer-fragments use domain-service status models instead), reducing
// the warning count from 3 to 2.
$this->assertSame(
2,
$count,
'Each of the 2 aggregators (actionEditContext, actionCreateContext) must carry the explicit CSRF-warning in its docblock (GR-SEC-001).'
);
}
public function testAggregatorsExistButAreNotFrozen(): void
{
// We assert existence only — signatures may evolve.
// Run 2026-04-25-action-fragment-context-removal removed
// actionFragmentContext; only EditContext and CreateContext remain.
$this->assertTrue(function_exists('actionEditContext'));
$this->assertTrue(function_exists('actionCreateContext'));
}
/**
* Ensure the `mixed` return type of building blocks is preserved where
* documented. Catches accidental tightening that would break Step 2.
*/
public function testResolveModelOrFailReturnsMixed(): void
{
$reflection = new ReflectionFunction('actionResolveModelOrFail');
$returnType = $reflection->getReturnType();
$this->assertInstanceOf(ReflectionNamedType::class, $returnType);
/** @var ReflectionNamedType $returnType */
$this->assertSame('mixed', $returnType->getName());
}
}