dootask/tests/Feature/TodoRemindTest.php
kuaifan 6b54b7b1c5 feat(todo): 聊天待办支持提醒时间(到点引用原消息+@提及)
给消息待办增加可选「提醒时间」,到点由 todo-alert 机器人对原消息发起
reply、正文 @ 仍在群内的被指派成员,完全复用原生回复/提及链路(定向未读、
红点、绕过会话免打扰、App 推送);被指派人全部退群则跳过发送并标记已提醒。
设/改/取消提醒的权限沿用 todo_set_permission 开关与 checkTodoOwnerPermission。

后端:
- 迁移:web_socket_dialog_msg_todos 增加 remind_at/reminded_at 及索引,
  注册为日期字段
- WebSocketDialogMsgTodo::dueReminders() 选取到点(未提醒/未完成)待办(limit 500)
- WebSocketDialogMsg::setTodoRemind() 纯数据写入(改时间重置 reminded_at),
  接入 toggleTodoMsg($remindAt) 与 msg__todo 透传
- 接口 msg__todoremind 设置/修改/取消提醒(权限闸门、消息类型校验、
  pushMsg 同步 todo_done)
- TodoRemindTask 到点按消息发提醒(reminded_at 防重复、迟发补发、原消息/
  会话删除兜底),buildRemindText 生成 <span class="mention user"> 文本,
  接入 crontab;登记 todo-alert 机器人
- msgJoinGroup 从提醒文本中提取被 @ 成员

前端:
- 设待办弹窗新增「提醒时间」(预设 + 自定义 DatePicker)
- 待办详情浮层每条待办可查看/修改/取消提醒:DatePicker on-clear「清空」
  二次确认后取消,无时间时仅关闭面板不发请求
- 待办浮层窄屏(≤500px)改为 待办/完成 tab 切换,宽屏维持双列;列表为空
  展示空状态占位;提醒时间用 Icon 替换 emoji
- 时间读写对齐项目任务时间的时区约定

测试:tests/Feature/TodoRemindTest(数据/选取/写入/权限决策/buildRemindText/
text mention 提取),TodoSetPermissionTest 无回归。

任务 #124 后续增强。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:08:34 +00:00

161 lines
6.5 KiB
PHP
Raw Permalink 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
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsgTodo;
use App\Tasks\TodoRemindTask;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* 待办提醒时间测试
*/
class TodoRemindTest extends TestCase
{
use DatabaseTransactions;
private function makeTodo(array $attr = []): WebSocketDialogMsgTodo
{
$todo = WebSocketDialogMsgTodo::createInstance(array_merge([
'dialog_id' => 1001,
'msg_id' => 2001,
'userid' => 3001,
], $attr));
$todo->save();
return $todo;
}
public function test_remind_columns_persist()
{
$todo = $this->makeTodo(['remind_at' => '2026-06-02 09:00:00']);
$fresh = WebSocketDialogMsgTodo::whereId($todo->id)->first();
$this->assertEquals('2026-06-02 09:00:00', $fresh->remind_at->format('Y-m-d H:i:s'));
$this->assertNull($fresh->reminded_at);
}
public function test_due_reminders_selects_only_due_unreminded_undone()
{
$past = Carbon::now()->subMinutes(5)->format('Y-m-d H:i:s');
$future = Carbon::now()->addHours(2)->format('Y-m-d H:i:s');
$due = $this->makeTodo(['userid' => 1, 'remind_at' => $past]);
$future1 = $this->makeTodo(['userid' => 2, 'remind_at' => $future]);
$already = $this->makeTodo(['userid' => 3, 'remind_at' => $past, 'reminded_at' => Carbon::now()]);
$done = $this->makeTodo(['userid' => 4, 'remind_at' => $past, 'done_at' => Carbon::now()]);
$noRemind = $this->makeTodo(['userid' => 5]);
$ids = WebSocketDialogMsgTodo::dueReminders()->pluck('id')->toArray();
$this->assertContains($due->id, $ids);
$this->assertNotContains($future1->id, $ids);
$this->assertNotContains($already->id, $ids);
$this->assertNotContains($done->id, $ids);
$this->assertNotContains($noRemind->id, $ids);
}
public function test_set_todo_remind_sets_and_resets_and_clears()
{
// 同一消息两人,预置已提醒状态以验证会被重置
$a = $this->makeTodo(['msg_id' => 5001, 'userid' => 11, 'reminded_at' => Carbon::now()]);
$b = $this->makeTodo(['msg_id' => 5001, 'userid' => 12, 'reminded_at' => Carbon::now()]);
$msg = new \App\Models\WebSocketDialogMsg();
$msg->id = 5001;
// 设提醒:写入 remind_at并把 reminded_at 重置为 null
$affected = $msg->setTodoRemind([11, 12], '2026-06-05 10:00:00');
$this->assertSame(2, $affected);
foreach ([$a, $b] as $row) {
$fresh = WebSocketDialogMsgTodo::whereId($row->id)->first();
$this->assertStringStartsWith('2026-06-05 10:00:00', $fresh->remind_at->format('Y-m-d H:i:s'));
$this->assertNull($fresh->reminded_at, '改时间后应允许再次提醒');
}
// 取消提醒remind_at 置 null
$msg->setTodoRemind([11], null);
$this->assertNull(WebSocketDialogMsgTodo::whereId($a->id)->first()->remind_at);
// 未传 userid 时不动任何行
$this->assertSame(0, $msg->setTodoRemind([], '2026-06-05 10:00:00'));
}
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $email,
'userimg' => '',
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
'profession' => '',
'password' => md5('123456'),
]);
$user->save();
return $user;
}
/**
* 镜像 msg__todoremind 的权限闸门:
* 开关 close 且改到「自己以外的人」时,需操作者命中 checkTodoOwnerPermission。
*
* 注意:此逻辑为 DialogController::msg__todoremind() 权限逻辑的镜像,
* 若 msg__todoremind 权限逻辑改动需同步更新此方法。
*/
private function remindGateAllow(WebSocketDialog $dialog, string $switch, int $sender, array $userids): bool
{
if ($switch !== 'close') {
return true;
}
$others = array_diff(array_map('intval', $userids), [$sender]);
if (!$others) {
return true; // 只改自己
}
return $dialog->checkTodoOwnerPermission($sender);
}
public function test_remind_edit_permission_follows_todo_gate()
{
$owner = $this->makeUser('r_o@test.local');
$member = $this->makeUser('r_m@test.local');
$dialog = WebSocketDialog::createGroup('Test_remind', [$owner->userid, $member->userid], 'user', $owner->userid)->fresh();
$this->assertTrue($this->remindGateAllow($dialog, 'close', $member->userid, [$member->userid])); // 改自己→放行
$this->assertFalse($this->remindGateAllow($dialog, 'close', $member->userid, [$owner->userid])); // 改他人→拒绝
$this->assertTrue($this->remindGateAllow($dialog, 'close', $owner->userid, [$member->userid])); // 群主改他人→放行
$this->assertTrue($this->remindGateAllow($dialog, 'open', $member->userid, [$owner->userid])); // open→放行
}
public function test_build_remind_text_produces_mention_spans()
{
$a = $this->makeUser('rt_a@test.local');
$b = $this->makeUser('rt_b@test.local');
$text = TodoRemindTask::buildRemindText([$a->userid, $b->userid]);
$this->assertStringContainsString("<span class=\"mention user\" data-id=\"{$a->userid}\">@{$a->nickname}</span>", $text);
$this->assertStringContainsString("<span class=\"mention user\" data-id=\"{$b->userid}\">@{$b->nickname}</span>", $text);
$this->assertStringContainsString('你有一条待办到提醒时间啦', $text);
}
public function test_msg_join_group_extracts_text_mention_from_spans()
{
$owner = $this->makeUser('tx_o@test.local');
$a = $this->makeUser('tx_a@test.local');
$b = $this->makeUser('tx_b@test.local');
$dialog = WebSocketDialog::createGroup('Test_text_mention', [$owner->userid, $a->userid, $b->userid], 'user', $owner->userid)->fresh();
$msg = new \App\Models\WebSocketDialogMsg();
$msg->dialog_id = $dialog->id;
$msg->userid = $owner->userid;
$msg->type = 'text';
$msg->msg = ['text' => TodoRemindTask::buildRemindText([$a->userid, $b->userid])];
$result = $msg->msgJoinGroup($dialog);
$mentions = array_map('intval', $result['mentions']);
sort($mentions);
$expected = [$a->userid, $b->userid];
sort($expected);
$this->assertEquals($expected, $mentions);
}
}