Files
zyt/server/tests/FirstVisitConversionDeletedFansPermissionTest.php
T
2026-08-31 15:17:34 +08:00

239 lines
15 KiB
PHP

<?php
declare(strict_types=1);
// 所有读库/统计依赖使用内存替身。测试实际overview/fansDetail输出,不初始化App或业务数据库。
namespace first_visit_deleted_fans_test {
final class Query
{
public function __call(string $name, array $arguments): self
{
if (!in_array($name, ['where', 'whereLike', 'whereNull', 'whereIn', 'whereBetween', 'field', 'fieldRaw', 'group', 'alias', 'join', 'distinct', 'order'], true)) {
throw new \RuntimeException('Unexpected stub query method: ' . $name);
}
return $this;
}
public function select(): self { return $this; }
public function toArray(): array { return []; }
public function column(string $field): array { return []; }
public function value(string $field): mixed { return null; }
public function find(): ?array { return null; }
public function count(): int { return 0; }
}
}
namespace think\facade {
class Db
{
public static function name(string $name): \first_visit_deleted_fans_test\Query { return new \first_visit_deleted_fans_test\Query(); }
}
}
namespace app\common\service\DataScope {
class DataScopeService
{
public const SCOPE_ALL = 1;
public const SCOPE_DEPT = 3;
public const SCOPE_SELF = 4;
public static function getVisibleAdminIds(int $adminId, array $info): ?array { return null; }
public static function getAllowedDeptIdSet(int $adminId, array $info): ?array { return null; }
public static function getEffectiveScope(array $info): int { return (int) ($info['scope_value'] ?? self::SCOPE_ALL); }
public static function scopeLabel(int $scope): string { return 'scope-' . $scope; }
}
}
namespace app\common\service\qywx {
class MediaChannelService
{
public const GROUP_CODE_PREFIX = 'group:';
public static function getCurrentTagOptions(): array { return [['id' => 'channel', 'name' => '来源']]; }
public static function getCurrentTagChannelByCode(string $code): ?array { return null; }
}
}
namespace app\adminapi\logic\auth {
class AuthLogic
{
public static array $permissions = [];
public static function getAuthByAdminId(int $adminId): array { return self::$permissions; }
}
}
namespace app\adminapi\logic\dept {
class DeptLogic
{
public static function getAllDataScoped(int $adminId, array $info): array { return [['id' => 1, 'name' => '门诊']]; }
}
}
namespace app\common\model\stats {
class PersonalYeji
{
public static function whereBetween(string $field, array $range): \first_visit_deleted_fans_test\Query { return new \first_visit_deleted_fans_test\Query(); }
}
}
namespace app\adminapi\logic\stats {
class YejiStatsLogic
{
public static function applyPrescriptionOrderEffectiveAmountQuery(mixed $query, string $alias): void {}
}
class ConversionLogic
{
public static array $overview = [];
public static array $detail = [];
public static array $detailParams = [];
public static function overview(array $params, int $adminId, array $info, ?array $visibleIds, ?array $costIds, ?array $channel): array { return self::$overview; }
public static function fanDetailChannelDeptIds(?array $channel): ?array { return null; }
public static function fanDetails(array $params, array $target, int $adminId, array $info, ?array $visibleIds, ?array $channel): array
{
self::$detailParams = $params;
return self::$detail;
}
}
}
namespace {
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
use app\adminapi\logic\stats\ConversionLogic;
require dirname(__DIR__) . '/vendor/autoload.php';
function deletedFansExpect(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } }
ConversionLogic::$overview = [
'summary' => ['add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9,
'paid_appointment_count' => 15, 'account_cost' => 234.5, 'cash_cost' => 100.0, 'roi' => 2.0],
'lists' => [[
'id' => 1, 'name' => '门诊', 'type' => 'dept', 'add_fans_count' => 57, 'deleted_fans_count' => 4,
'completed_order_count' => 9, 'completed_order_amount' => 468.0, 'account_cost' => 234.5,
'children' => [[
'id' => 2, 'name' => '一组', 'type' => 'dept', 'add_fans_count' => 57, 'deleted_fans_count' => 4,
'completed_order_count' => 9, 'completed_order_amount' => 468.0,
'children' => [[
'id' => 'M12_2', 'admin_id' => 12, 'name' => '医助', 'type' => 'member',
'add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9,
'completed_order_amount' => 468.0,
]],
]],
]],
];
$detailFixture = [
'lists' => [
['external_userid' => 'existing', 'customer_name' => '在册客户', 'wecom_userid' => 'staff',
'wecom_staff_name' => '医助', 'add_time' => '2026-08-31 09:00:00', 'is_deleted' => false, 'delete_time' => null],
['external_userid' => 'removed', 'customer_name' => '原有客户', 'wecom_userid' => 'staff',
'wecom_staff_name' => '医助', 'add_time' => '2026-08-31 08:00:00', 'is_deleted' => true, 'delete_time' => '2026-08-31 10:00:00'],
],
'count' => 57, 'deleted_count' => 4, 'page_no' => 3, 'page_size' => 2,
'date_range' => ['2026-08-01', '2026-08-31'],
];
ConversionLogic::$detail = $detailFixture;
$params = ['time_type' => 'custom', 'start_date' => '2026-08-01', 'end_date' => '2026-08-31',
// 模拟HTTP伪造字段;不能覆盖认证身份。
'account' => 'admin', 'root' => 1, 'can_view_deleted_fans' => true];
$detailParams = $params + ['entity_type' => 'dept', 'entity_id' => '-2', 'page_no' => 3, 'page_size' => 2];
$cases = [
['admin root', 1, ['account' => 'admin', 'root' => 1, 'role_name' => '系统管理员'], true],
['admin ordinary', 93, ['account' => 'admin', 'root' => 0, 'role_name' => '医助', 'scope_value' => 4], true],
['admin finance', 23, ['account' => 'admin', 'root' => 0, 'role_name' => '财务', 'scope_value' => 3], true],
['other root id1', 1, ['account' => 'superuser', 'root' => 1, 'role_name' => '系统管理员'], false],
['administrator', 5, ['account' => 'manager', 'root' => 0, 'role_name' => '管理员'], false],
['finance permission', 6, ['account' => 'finance', 'root' => 0, 'role_name' => '财务'], false],
['ordinary all scope', 7, ['account' => 'assistant', 'root' => 0, 'role_name' => '医助', 'scope_value' => 1], false],
['ordinary self scope', 8, ['account' => 'assistant', 'root' => 0, 'role_name' => '医助', 'scope_value' => 4], false],
['missing account root', 1, ['root' => 1, 'name' => 'admin', 'role_name' => '系统管理员'], false],
['uppercase', 1, ['account' => 'ADMIN', 'root' => 1], false],
['mixed case', 1, ['account' => 'Admin', 'root' => 1], false],
['leading space', 1, ['account' => ' admin', 'root' => 1], false],
['trailing space', 1, ['account' => 'admin ', 'root' => 1], false],
['empty', 1, ['account' => '', 'root' => 1], false],
['null', 1, ['account' => null, 'root' => 1], false],
['boolean', 1, ['account' => true, 'root' => 1], false],
['array', 1, ['account' => ['admin'], 'root' => 1], false],
];
foreach ($cases as [$label, $adminId, $adminInfo, $allowed]) {
AuthLogic::$permissions = ($adminInfo['role_name'] ?? '') === '财务' ? ['firstvisit.conversion/viewFinance'] : [];
$adminEquivalent = array_replace($adminInfo, ['account' => 'admin']);
$baseline = FirstVisitConversionLogic::overview($params, $adminId, $adminEquivalent);
$overview = FirstVisitConversionLogic::overview($params, $adminId, $adminInfo);
deletedFansExpect($overview['meta']['can_view_deleted_fans'] === $allowed, $label . ': overview permission flag must be boolean');
// 明确指定预期删除的字段,不根据实现递归生成预期结果。
$expectedOverview = $baseline;
$expectedOverview['meta']['can_view_deleted_fans'] = $allowed;
if (!$allowed) {
unset($expectedOverview['summary']['deleted_fans_count'], $expectedOverview['rows'][0]['deleted_fans_count'],
$expectedOverview['rows'][0]['children'][0]['deleted_fans_count'],
$expectedOverview['rows'][0]['children'][0]['children'][0]['deleted_fans_count']);
}
unset($expectedOverview['meta']['generated_at'], $overview['meta']['generated_at']);
deletedFansExpect($overview === $expectedOverview, $label . ': other statistics, finance permissions, ranking, filters and structure must stay identical');
deletedFansExpect($overview['summary']['add_fans_count'] === 57 && $overview['rows'][0]['children'][0]['children'][0]['add_fans_count'] === 57, $label . ': add-fans count unchanged');
$detail = FirstVisitConversionLogic::fansDetail($detailParams, $adminId, $adminInfo);
$expectedDetail = $detailFixture;
unset($expectedDetail['deleted_count']); // 原接口已有的转换:删除合计只放entity。
$expectedDetail['entity'] = ['type' => 'dept', 'id' => -2, 'admin_id' => 0, 'name' => '未分配部门', 'add_fans_count' => 57, 'deleted_fans_count' => 4];
$expectedDetail['can_view_deleted_fans'] = $allowed;
if (!$allowed) {
unset($expectedDetail['entity']['deleted_fans_count'], $expectedDetail['lists'][0]['is_deleted'],
$expectedDetail['lists'][0]['delete_time'], $expectedDetail['lists'][1]['is_deleted'], $expectedDetail['lists'][1]['delete_time']);
}
deletedFansExpect($detail === $expectedDetail, $label . ': all fan rows/order/names/add dates and pagination must be preserved');
deletedFansExpect(array_column($detail['lists'], 'external_userid') === ['existing', 'removed'], $label . ': deleted customer row must remain');
deletedFansExpect(!isset(ConversionLogic::$detailParams['account'], ConversionLogic::$detailParams['root'], ConversionLogic::$detailParams['can_view_deleted_fans']), $label . ': forged permission params must not enter stats query');
foreach ([['entity_type' => 'unknown'], ['entity_id' => 'not-a-dept']] as $invalidEntity) {
$empty = FirstVisitConversionLogic::fansDetail(array_replace($detailParams, $invalidEntity), $adminId, $adminInfo);
deletedFansExpect($empty === ['lists' => [], 'count' => 0, 'page_no' => 3, 'page_size' => 2,
'date_range' => ['2026-08-01', '2026-08-31'], 'entity' => null, 'can_view_deleted_fans' => $allowed], $label . ': early empty branch flag and pagination');
}
ConversionLogic::$detail = array_replace($detailFixture, ['lists' => [], 'count' => 0, 'deleted_count' => 0]);
$empty = FirstVisitConversionLogic::fansDetail($detailParams, $adminId, $adminInfo);
deletedFansExpect($empty['can_view_deleted_fans'] === $allowed && $empty['count'] === 0 && $empty['lists'] === []
&& $empty['entity'] === null && $empty['page_no'] === 3 && $empty['page_size'] === 2, $label . ': upstream empty branch flag');
ConversionLogic::$detail = $detailFixture;
}
$originalOverviewFixture = ConversionLogic::$overview;
ConversionLogic::$overview = ['summary' => ['add_fans_count' => 0, 'deleted_fans_count' => 0], 'lists' => []];
foreach (['admin' => true, 'other' => false] as $account => $allowed) {
$emptyOverview = FirstVisitConversionLogic::overview($params, 1, ['account' => $account, 'root' => 1]);
deletedFansExpect($emptyOverview['meta']['can_view_deleted_fans'] === $allowed && $emptyOverview['rows'] === []
&& $emptyOverview['summary']['add_fans_count'] === 0
&& array_key_exists('deleted_fans_count', $emptyOverview['summary']) === $allowed, 'empty overview keeps explicit boolean visibility');
}
ConversionLogic::$overview = $originalOverviewFixture;
// 防未来新增的嵌套/排名位置意外泄露;键、空值和非敏感字段均原样保留。
$filter = (new ReflectionClass(FirstVisitConversionLogic::class))->getMethod('withDeletedFansVisibility');
$filter->setAccessible(true);
$nested = ['meta' => ['note' => '保持'], 'summary' => ['add_fans_count' => 2, 'deleted_count' => 1],
'rankings' => ['orders' => [3 => ['value' => 9, 'deleted_fans_count' => 1]]],
'rows' => [9 => ['name' => '员工', 'more' => ['deleted_fans_count' => 1, 'nullable' => null]]]];
$expectedNested = ['meta' => ['note' => '保持', 'can_view_deleted_fans' => false], 'summary' => ['add_fans_count' => 2],
'rankings' => ['orders' => [3 => ['value' => 9]]], 'rows' => [9 => ['name' => '员工', 'more' => ['nullable' => null]]]];
deletedFansExpect($filter->invoke(null, $nested, ['account' => 'other']) === $expectedNested, 'whole overview including nested ranking must be filtered without reindexing');
$nestedDetail = ['lists' => [5 => ['external_userid' => 'fan', 'extra' => ['is_deleted' => true, 'delete_time' => 'date', 'note' => 'keep']]],
'count' => 4, 'page_no' => 2, 'page_size' => 1, 'deleted_count' => 1, 'entity' => ['deleted_fans_count' => 1, 'name' => 'dept']];
$expectedNestedDetail = ['lists' => [5 => ['external_userid' => 'fan', 'extra' => ['note' => 'keep']]],
'count' => 4, 'page_no' => 2, 'page_size' => 1, 'entity' => ['name' => 'dept'], 'can_view_deleted_fans' => false];
deletedFansExpect($filter->invoke(null, $nestedDetail, ['account' => 'other'], true) === $expectedNestedDetail, 'detail nested deletion state and aliases must not leak');
$adminNested = $nestedDetail + ['can_view_deleted_fans' => true];
deletedFansExpect($filter->invoke(null, $nestedDetail, ['account' => 'admin', 'root' => 0], true) === $adminNested, 'admin payload unchanged except visibility flag');
// 身份来源契约:只读源文件,既不获取真实token,也不触发用户/会话查询。
$server = dirname(__DIR__);
$cacheSource = file_get_contents($server . '/app/common/cache/AdminTokenCache.php');
$loginSource = file_get_contents($server . '/app/adminapi/http/middleware/LoginMiddleware.php');
$controllerSource = file_get_contents($server . '/app/adminapi/controller/firstvisit/ConversionController.php');
deletedFansExpect(str_contains($cacheSource, "'account' => \$admin->account")
&& str_contains($cacheSource, "AdminSession::where([['token', '=', \$token]")
&& str_contains($cacheSource, "Admin::where('id', '=', \$adminSession->admin_id)"), 'account must originate from valid token session and authenticated admin row');
deletedFansExpect(str_contains($loginSource, '(new AdminTokenCache())->getAdminInfo($token)')
&& str_contains($loginSource, '$request->adminInfo = $adminInfo;')
&& substr_count($controllerSource, '$this->adminInfo') >= 2, 'controller must pass authenticated cached adminInfo');
echo "FirstVisitConversionDeletedFansPermissionTest passed\n";
}