Files
ulthon_admin/tests/Feature/NoticeServiceTest.php
2026-10-12 13:40:40 +00:00

411 lines
17 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 tests\Feature;
use app\common\service\NoticeService;
use app\common\test\TestCase;
use think\facade\Cache;
use think\facade\Db;
/**
* NoticeService 核心行为集成测试.
*
* 覆盖 11 个契约点(与计划任务 3 逐项对齐,每项一个 test 方法):
* 1. send 主体 + target 展开
* 2. 'all' 广播(仅 status=1,含无 email 管理员)
* 3. channels 缺省按 scene.channels 展开
* 4. 订阅过滤(退订行拦截投递)
* 5. locked 场景强投递
* 6. locked 场景请求未注册渠道被 ∩ 掉(零 target)
* 7. 未注册 scene 放行(缺省 [inmail])
* 8. 无 email 跳过 email 渠道
* 9. inmail 全局总闸关闭跳过(键不存在视为开)
* 10. unreadCount/markRead/markAllRead 数字正确
* 11. markRead 越权防护
*
* fixture 均为事务内自造数据,tearDown 自动回滚;缓存清理显式执行
* (Cache 不受事务回滚影响,见 setInmailSwitch/clearInmailSwitch)。
*/
class NoticeServiceTest extends TestCase
{
// =========================================================================
// 1. send 主体 + target 展开
// =========================================================================
public function test_send_creates_notice_and_expands_inmail_target(): void
{
$adminId = $this->createAdmin();
$noticeId = NoticeService::send([$adminId], '测试标题', '测试内容');
$this->assertGreaterThan(0, $noticeId);
$this->assertDatabaseHas('system_notice', [
'id' => $noticeId,
'scene' => 'common',
'title' => '测试标题',
'content' => '测试内容',
'biz_id' => '',
'send_admin_id' => 0,
]);
$target = Db::name('system_notice_target')
->where('notice_id', $noticeId)
->where('admin_id', $adminId)
->find();
$this->assertNotNull($target, 'target 行应存在');
$this->assertSame('inmail', $target['channel']);
$this->assertSame(2, (int) $target['status'], 'inmail 投递即落库,直接成功');
$this->assertSame('', $target['target_addr'], 'inmail 无需外部地址');
$this->assertGreaterThan(0, (int) $target['send_time']);
$this->assertSame(1, Db::name('system_notice_target')->where('notice_id', $noticeId)->count());
}
// =========================================================================
// 2. 'all' 广播
// =========================================================================
public function test_send_all_broadcasts_to_active_admins_only(): void
{
// 记录广播前的启用管理员基数(测试库含初始数据,不能假设从 0 开始)
$activeBefore = Db::name('system_admin')->where('status', 1)->count();
$withMailBefore = Db::name('system_admin')->where('status', 1)->where('email', '<>', '')->count();
$adminWithMail = $this->createAdmin(['email' => 'all_a@test.com']);
$adminNoMail = $this->createAdmin();
$adminDisabled = $this->createAdmin(['status' => 0]);
$noticeId = NoticeService::send('all', '广播标题', '广播内容');
$this->assertGreaterThan(0, $noticeId);
// 总展开数 = 全体启用管理员各 1 行 inmail + 有 email 的管理员各 1 行 email
$expectedTotal = ($activeBefore + 2) + ($withMailBefore + 1);
$actualTotal = Db::name('system_notice_target')->where('notice_id', $noticeId)->count();
$this->assertSame($expectedTotal, $actualTotal, "target 总数应为 {$expectedTotal},实际 {$actualTotal}");
// 禁用管理员不在广播内
$this->assertSame(
0,
Db::name('system_notice_target')->where('notice_id', $noticeId)->where('admin_id', $adminDisabled)->count(),
'status=0 的管理员不应收到广播'
);
// 无 email 管理员仅 inmail 一行
$this->assertSame(1, Db::name('system_notice_target')
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->count());
$this->assertSame(1, Db::name('system_notice_target')
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->where('channel', 'inmail')->count());
// 有 email 管理员 inmail + email 两行,email 行待发
$this->assertSame(2, Db::name('system_notice_target')
->where('notice_id', $noticeId)->where('admin_id', $adminWithMail)->count());
$emailRow = Db::name('system_notice_target')
->where('notice_id', $noticeId)->where('admin_id', $adminWithMail)->where('channel', 'email')->find();
$this->assertNotNull($emailRow);
$this->assertSame(0, (int) $emailRow['status'], 'email 渠道应置待发由消费者投递');
$this->assertSame('all_a@test.com', $emailRow['target_addr']);
}
// =========================================================================
// 3. channels 缺省按 scene.channels 展开
// =========================================================================
public function test_send_without_channels_expands_scene_channels(): void
{
$adminNoMail = $this->createAdmin();
$adminWithMail = $this->createAdmin(['email' => 'scene@test.com']);
// admin_login 注册渠道 [inmail,email],不传 channels 按场景展开
$noticeId = NoticeService::send([$adminNoMail, $adminWithMail], '登录提醒', '内容', ['scene' => 'admin_login']);
// 无 email 管理员:email 被跳过,仅 inmail 一行
$this->assertSame(1, Db::name('system_notice_target')
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->count());
$this->assertSame(1, Db::name('system_notice_target')
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->where('channel', 'inmail')->count());
// 有 email 管理员:两行,email 行地址正确
$this->assertSame(2, Db::name('system_notice_target')
->where('notice_id', $noticeId)->where('admin_id', $adminWithMail)->count());
$this->assertDatabaseHas('system_notice_target', [
'notice_id' => $noticeId,
'admin_id' => $adminWithMail,
'channel' => 'email',
'target_addr' => 'scene@test.com',
'status' => 0,
]);
}
// =========================================================================
// 4. 订阅过滤
// =========================================================================
public function test_unsubscribe_row_filters_channel_delivery(): void
{
$adminId = $this->createAdmin();
// 默认订阅:第一次 send 产生 inmail 行
$noticeId1 = NoticeService::send([$adminId], '第一次', '内容');
$this->assertSame(1, Db::name('system_notice_target')->where('notice_id', $noticeId1)->count());
// 显式退订 common 场景的 inmail
$this->insertSubscribe($adminId, 'common', 'inmail', 0);
$noticeId2 = NoticeService::send([$adminId], '第二次', '内容');
// notice 主体仍存在,但零 target(email 渠道因无地址跳过,inmail 被退订过滤)
$this->assertDatabaseHas('system_notice', ['id' => $noticeId2]);
$this->assertSame(
0,
Db::name('system_notice_target')->where('notice_id', $noticeId2)->count(),
'全部退订 common 后不应产生任何 target 行'
);
}
// =========================================================================
// 5. locked 强投递
// =========================================================================
public function test_locked_scene_forces_delivery_despite_unsubscribe(): void
{
$adminId = $this->createAdmin();
$this->insertSubscribe($adminId, 'system', 'inmail', 0);
$noticeId = NoticeService::send([$adminId], '系统公告', '内容', ['scene' => 'system']);
$target = Db::name('system_notice_target')
->where('notice_id', $noticeId)
->where('admin_id', $adminId)
->find();
$this->assertNotNull($target, 'locked 场景即使退订也强制投递');
$this->assertSame('inmail', $target['channel']);
$this->assertSame(2, (int) $target['status']);
}
// =========================================================================
// 6. locked 场景请求未注册渠道被 ∩ 掉
// =========================================================================
public function test_locked_scene_intersects_requested_channels(): void
{
$adminId = $this->createAdmin(['email' => 'locked@test.com']);
// system 场景注册渠道仅 [inmail];locked 不豁免 ∩,请求 email 应被裁掉 → 零 target
$noticeId = NoticeService::send([$adminId], '系统公告', '内容', ['scene' => 'system', 'channels' => ['email']]);
$this->assertDatabaseHas('system_notice', ['id' => $noticeId]);
$this->assertSame(
0,
Db::name('system_notice_target')->where('notice_id', $noticeId)->count(),
'locked 场景请求未注册渠道应被 ∩ 掉,零 target'
);
}
// =========================================================================
// 7. 未注册 scene 放行
// =========================================================================
public function test_unregistered_scene_passes_with_default_inmail(): void
{
$adminId = $this->createAdmin();
// 未注册场景:Log::warning 后放行,缺省渠道 [inmail]。
// warning 断言取舍:框架 Log::listen 实为 LogWrite 事件监听,
// 仅在日志通道 save 时触发(请求收尾),CLI 测试进程不触发,
// 监听式断言不可行 —— 此处断言行为(放行 + 缺省 inmail 展开)。
$noticeId = NoticeService::send([$adminId], '业务自定义', '内容', ['scene' => 'totally_new_scene']);
$this->assertDatabaseHas('system_notice', ['id' => $noticeId, 'scene' => 'totally_new_scene']);
$this->assertSame(1, Db::name('system_notice_target')->where('notice_id', $noticeId)->count());
$this->assertDatabaseHas('system_notice_target', [
'notice_id' => $noticeId,
'channel' => 'inmail',
'status' => 2,
]);
}
// =========================================================================
// 8. 无 email 跳过 email 渠道
// =========================================================================
public function test_missing_email_skips_email_channel(): void
{
$adminId = $this->createAdmin();
// 显式请求 email 渠道但管理员无 email 值:跳过该渠道,不写行
$noticeId = NoticeService::send([$adminId], '标题', '内容', ['scene' => 'common', 'channels' => ['email']]);
$this->assertDatabaseHas('system_notice', ['id' => $noticeId]);
$this->assertSame(
0,
Db::name('system_notice_target')->where('notice_id', $noticeId)->count(),
'无 email 值的管理员不应产生 email target 行'
);
}
// =========================================================================
// 9. inmail 全局总闸
// =========================================================================
public function test_inmail_global_switch_off_skips_inmail(): void
{
$adminId = $this->createAdmin();
// 插入关闭配置行(value='0')并清 sysconfig 缓存(缓存不受事务回滚影响)
$this->setInmailSwitch('0');
$noticeId1 = NoticeService::send([$adminId], '总闸关闭', '内容');
$this->assertDatabaseHas('system_notice', ['id' => $noticeId1]);
$this->assertSame(
0,
Db::name('system_notice_target')->where('notice_id', $noticeId1)->count(),
'notify_inmail_enable=0 时 inmail 渠道应全局跳过'
);
// 移除配置行:键不存在视为开(全局总闸默认放行)
$this->clearInmailSwitch();
$noticeId2 = NoticeService::send([$adminId], '总闸恢复', '内容');
$this->assertDatabaseHas('system_notice_target', [
'notice_id' => $noticeId2,
'channel' => 'inmail',
'status' => 2,
]);
}
// =========================================================================
// 10. unreadCount / markRead / markAllRead
// =========================================================================
public function test_unread_count_and_read_actions(): void
{
$adminId = $this->createAdmin();
NoticeService::send([$adminId], '第一条', '内容');
NoticeService::send([$adminId], '第二条', '内容');
NoticeService::send([$adminId], '第三条', '内容');
$this->assertSame(3, NoticeService::unreadCount($adminId));
$targetIds = Db::name('system_notice_target')
->where('admin_id', $adminId)
->where('read_time', 0)
->column('id');
// 标记两条已读
$marked = NoticeService::markRead([$targetIds[0], $targetIds[1]], $adminId);
$this->assertSame(2, $marked);
$this->assertSame(1, NoticeService::unreadCount($adminId));
$this->assertGreaterThan(0, (int) Db::name('system_notice_target')->where('id', $targetIds[0])->value('read_time'));
// 重复标记不再增加
$this->assertSame(0, NoticeService::markRead([$targetIds[0]], $adminId), '已读行不应被重复标记');
// 全部已读
$this->assertSame(1, NoticeService::markAllRead($adminId));
$this->assertSame(0, NoticeService::unreadCount($adminId));
}
// =========================================================================
// 11. markRead 越权防护
// =========================================================================
public function test_mark_read_cannot_touch_other_admins_rows(): void
{
$adminA = $this->createAdmin();
$adminB = $this->createAdmin();
NoticeService::send([$adminA], 'A的通知', '内容');
NoticeService::send([$adminB], 'B的通知', '内容');
$this->assertSame(1, NoticeService::unreadCount($adminA));
$this->assertSame(1, NoticeService::unreadCount($adminB));
$bTargetId = (int) Db::name('system_notice_target')
->where('admin_id', $adminB)
->value('id');
// adminA 尝试读 adminB 的行:影响 0 行,adminB 数据不受影响
$this->assertSame(0, NoticeService::markRead([$bTargetId], $adminA), 'markRead 不得触碰他人行');
$this->assertSame(0, (int) Db::name('system_notice_target')->where('id', $bTargetId)->value('read_time'));
$this->assertSame(1, NoticeService::unreadCount($adminB));
// markAllRead 同样带 admin_id 条件:只影响自己
NoticeService::markAllRead($adminA);
$this->assertSame(0, NoticeService::unreadCount($adminA));
$this->assertSame(1, NoticeService::unreadCount($adminB), 'markAllRead 不得触碰他人行');
}
// =========================================================================
// fixture 工厂(事务内运行,tearDown 自动回滚)
// =========================================================================
/**
* 造启用管理员.
*
* @param array $overrides 字段覆盖(email/status 等)
* @return int 管理员ID
*/
private function createAdmin(array $overrides = []): int
{
$data = array_merge([
'username' => 'ntc_' . uniqid(),
'password' => 'not-a-real-password',
'nickname' => '通知测试用户',
'email' => '',
'status' => 1,
'create_time' => time(),
'update_time' => time(),
'delete_time' => 0,
], $overrides);
return (int) Db::name('system_admin')->insertGetId($data);
}
/**
* 插入订阅矩阵行.
*/
private function insertSubscribe(int $adminId, string $scene, string $channel, int $status): void
{
Db::name('system_notice_subscribe')->insert([
'admin_id' => $adminId,
'scene' => $scene,
'channel' => $channel,
'status' => $status,
'create_time' => time(),
'update_time' => time(),
]);
}
/**
* 写 notify 组 inmail 总闸配置行并清缓存.
*
* sysconfig 缓存不受事务回滚影响:非 '0' 值会缓存 3600 秒,
* 必须显式清 tag 才能让后续读取看到事务内的新值。
*/
private function setInmailSwitch(string $value): void
{
Db::name('system_config')->insert([
'name' => 'notify_inmail_enable',
'group' => 'notify',
'value' => $value,
'remark' => 'NoticeServiceTest fixture',
'sort' => 100,
'create_time' => time(),
'update_time' => time(),
]);
Cache::tag('sysconfig')->clear();
}
/**
* 删除 notify 组 inmail 总闸配置行并清缓存.
*/
private function clearInmailSwitch(): void
{
Db::name('system_config')
->where('group', 'notify')
->where('name', 'notify_inmail_enable')
->delete();
Cache::tag('sysconfig')->clear();
}
}