dootask/tests/Feature/AppPushTest.php
kuaifan a49ee608a5 feat(push): 新增 DooPush 推送通道,与友盟并存
- 新增 app_push_devices 表与 api/push/device 接口(新版 App 上报/删除 DooPush 设备)
- app/Module/AppPush 统一分发友盟与 DooPush,业务字段经 payload.data 下发
- 系统设置「App 推送」新增 DooPush 配置;点击通知打开对应会话
- 更新 Expo 壳子模块(接入 DooPush)、语言包与 ai-kb
2026-10-19 10:33:19 +00:00

329 lines
15 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
namespace Tests\Feature;
use App\Models\AppPushDevice;
use App\Models\UmengAlias;
use App\Models\User;
use App\Models\UserDevice;
use App\Models\WebSocketDialogMsgRead;
use App\Module\AppPush\AppPush;
use App\Module\AppPush\DooPushClient;
use App\Module\Base;
use App\Module\Doo;
use App\Services\RequestContext;
use App\Tasks\PushUmengMsg;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class AppPushTest extends TestCase
{
use DatabaseTransactions;
private $originalDispatcher;
protected function setUp(): void
{
parent::setUp();
config(['cache.default' => 'array']);
$this->originalDispatcher = Model::getEventDispatcher();
// 测试数据不触发无关的搜索同步和 Swoole 任务。
Model::setEventDispatcher(new Dispatcher(app()));
RequestContext::clean();
$this->settings();
}
protected function tearDown(): void
{
RequestContext::clean();
Model::setEventDispatcher($this->originalDispatcher);
parent::tearDown();
}
private function settings(array $overrides = []): void
{
Base::setting('appPushSetting', array_merge([
'push' => 'close',
'doopush' => 'open',
'doopush_app_id' => '123',
'doopush_app_secret' => 'test-secret',
'doopush_base_url' => '',
], $overrides));
}
private function userToken(bool $admin = false): array
{
$user = User::createInstance([
'email' => uniqid('app_push_', true) . '@test.local',
'nickname' => 'Push test',
'encrypt' => bin2hex(random_bytes(8)),
'identity' => $admin ? 'admin' : 'normal',
]);
$user->save();
$token = Doo::tokenEncode($user->userid, $user->email, $user->encrypt, 1);
UserDevice::record($token);
return [$user, $token];
}
private function device(int $userid, string $id, array $overrides = []): AppPushDevice
{
$device = AppPushDevice::createInstance(array_merge([
'userid' => $userid, 'channel' => 'doopush', 'device_id' => $id,
'vendor' => 'apns', 'platform' => 'ios', 'device_hash' => md5('test-token'),
], $overrides));
$device->save();
return $device;
}
private function alias(string $alias, string $hash): UmengAlias
{
$row = UmengAlias::createInstance(['userid' => 1, 'alias' => $alias, 'platform' => 'ios', 'device_hash' => $hash]);
$row->save();
return $row;
}
public function test_get_and_post_update_transfer_device_and_escape_legacy_alias_suffix(): void
{
[$first, $token] = $this->userToken();
$uniqueId = 'a%_!\\b';
$sameSuffix = $this->alias('iOS-1-' . $uniqueId, md5('older-token'));
$sameHash = $this->alias('android-1-old001', md5($token));
$unrelated = $this->alias('iOS-1-aXX!\\b', md5('other-token'));
$data = [
'device_id' => '94001', 'vendor' => 'apns', 'unique_id' => $uniqueId,
'device_model' => 'iPhone', 'app_version' => '245', 'app_version_name' => '1.9.66',
'user_agent' => 'Push test UA', 'is_notified' => 'true',
];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'iOS'])
->assertOk()->assertJsonPath('ret', 1);
$row = AppPushDevice::whereDeviceId('94001')->sole();
$this->assertSame($first->userid, $row->userid);
$this->assertSame(md5($token), $row->device_hash);
$this->assertSame('1.9.66 (245)', $row->version);
$this->assertSame('ios', $row->platform);
$this->assertSame('iPhone', $row->device);
$this->assertSame('Push test UA', $row->ua);
$this->assertSame(1, $row->is_notified);
$this->assertNull($sameSuffix->fresh());
$this->assertNull($sameHash->fresh());
$this->assertNotNull($unrelated->fresh());
[$second, $secondToken] = $this->userToken();
$this->getJson('/api/push/device?' . http_build_query([
'device_id' => '94001', 'vendor' => 'fcm', 'is_notified' => 1,
]), ['token' => $secondToken, 'platform' => 'ANDROID'])
->assertOk()->assertJsonPath('ret', 1);
$updated = AppPushDevice::whereDeviceId('94001')->sole();
$this->assertSame($row->id, $updated->id);
$this->assertSame($second->userid, $updated->userid);
$this->assertSame(md5($secondToken), $updated->device_hash);
$this->assertSame($row->created_at->toDateTimeString(), $updated->created_at->toDateTimeString());
$this->assertSame('android', $updated->platform);
$this->assertSame(1, $updated->is_notified);
}
public function test_short_unique_id_does_not_delete_other_legacy_bindings(): void
{
[, $token] = $this->userToken();
$alias = $this->alias('iOS-1-abcd', md5('other-token'));
$this->postJson('/api/push/device', [
'device_id' => '94002', 'vendor' => 'apns', 'unique_id' => 'abcd', 'is_notified' => true,
], ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 1);
$this->assertNotNull($alias->fresh());
$this->assertSame(1, AppPushDevice::whereDeviceId('94002')->sole()->is_notified);
}
public function test_remove_accepts_invalid_token_but_requires_matching_hash_id_and_channel(): void
{
[, $token] = $this->userToken();
[, $wrongToken] = $this->userToken();
// 使用真实格式但已撤销的 token,避免格式错误字符串触发原生解码器异常。
UserDevice::whereHash(md5($token))->delete();
Cache::forget(UserDevice::ck(md5($token)));
$row = $this->device(1, '94003', ['device_hash' => md5($token)]);
$otherChannel = $this->device(1, '94003', ['channel' => 'other', 'device_hash' => md5($token)]);
$otherId = $this->device(1, '94004', ['device_hash' => md5($token)]);
$data = ['action' => 'remove', 'device_id' => '94003'];
$this->postJson('/api/push/device', $data)->assertJsonPath('ret', 1);
$this->postJson('/api/push/device', $data, ['token' => $wrongToken])->assertJsonPath('ret', 1);
$this->assertNotNull($row->fresh());
$this->getJson('/api/push/device?' . http_build_query($data), ['token' => $token])->assertJsonPath('ret', 1);
$this->assertNull($row->fresh());
$this->assertNotNull($otherChannel->fresh());
$this->assertNotNull($otherId->fresh());
}
public function test_update_validates_token_platform_vendor_and_digit_string(): void
{
foreach (['', '-1', '12a', str_repeat('1', 65), ['12'], 123] as $id) {
$this->postJson('/api/push/device', ['device_id' => $id])->assertJsonPath('ret', 0);
}
$this->postJson('/api/push/device', ['device_id' => '94005', 'vendor' => 'apns'])->assertJsonPath('ret', -1);
[, $token] = $this->userToken();
$this->postJson('/api/push/device', ['device_id' => '94005', 'vendor' => 'apns'], [
'token' => $token, 'platform' => 'web',
])->assertJsonPath('ret', 0);
$this->postJson('/api/push/device', ['device_id' => '94005', 'vendor' => 'unknown'], [
'token' => $token, 'platform' => 'ios',
])->assertJsonPath('ret', 0);
$this->assertFalse(AppPushDevice::whereDeviceId('94005')->exists());
}
public function test_logout_cleans_both_device_bindings(): void
{
$token = 'logout-test-token';
$row = $this->device(1, '94006', ['device_hash' => md5($token)]);
$alias = $this->alias('ios-1-logout', md5($token));
$other = $this->device(1, '94007');
UserDevice::forget($token);
$this->assertNull($row->fresh());
$this->assertNull($alias->fresh());
$this->assertNotNull($other->fresh());
}
public function test_settings_default_and_save_preserve_both_channels_and_filter_unknown_keys(): void
{
Base::setting('appPushSetting', ['push' => 'close']);
[, $token] = $this->userToken(true);
$this->getJson('/api/system/setting/apppush', ['token' => $token])
->assertJsonPath('ret', 1)->assertJsonPath('data.doopush', 'close');
config(['dootask.system_setting' => 'enabled']);
$this->postJson('/api/system/setting/apppush', [
'type' => 'save', 'push' => 'open', 'ios_key' => 'old-key', 'ios_secret' => 'old-secret',
'doopush' => 'open', 'doopush_app_id' => '123', 'doopush_app_secret' => 'new-secret',
'doopush_base_url' => 'https://push.test/api/v1/', 'unexpected' => 'ignored',
], ['token' => $token])->assertJsonPath('ret', 1)
->assertJsonPath('data.push', 'open')->assertJsonPath('data.ios_key', 'old-key')
->assertJsonPath('data.doopush', 'open')->assertJsonPath('data.doopush_app_id', '123')
->assertJsonPath('data.doopush_app_secret', 'new-secret')
->assertJsonPath('data.doopush_base_url', 'https://push.test/api/v1/')->assertJsonMissingPath('data.unexpected');
}
public function test_send_groups_latest_five_active_devices_per_user_with_unread_badge_and_data_payload(): void
{
$uid = 910001;
$otherUid = 910002;
for ($i = 1; $i <= 7; $i++) {
$this->device($uid, (string)(95000 + $i), ['updated_at' => now()->subMinutes(8 - $i)]);
}
$this->device($uid, '95999', ['updated_at' => now()->subMonths(2)]);
$this->device($uid, '95998', ['channel' => 'other']);
$this->device($otherUid, '96001');
foreach ([['silence' => 0], ['silence' => 0], ['silence' => 1], ['silence' => 0, 'read_at' => now()]] as $i => $fields) {
WebSocketDialogMsgRead::createInstance(array_merge(['userid' => $uid, 'msg_id' => 97000 + $i], $fields))->save();
}
$client = new RecordingDooPushClient();
$client->pushToUsers([$uid, $uid, $otherUid, -1], [
'title' => "标题\n", 'body' => "正文\r\n第二行", 'extra' => ['dialog_id' => 41, 'msg_id' => 42],
]);
$this->assertCount(2, $client->requests);
$request = $client->requests[0];
$this->assertSame('https://doopush.com/api/v1/apps/123/push', $request['url']);
$this->assertSame('Bearer test-secret', $request['headers']['Authorization']);
$this->assertSame('application/json', $request['headers']['Content-Type']);
$body = $request['body'];
$this->assertSame(['type' => 'devices', 'device_ids' => [95007, 95006, 95005, 95004, 95003]], $body['target']);
$this->assertSame('标题', $body['title']);
$this->assertSame('正文第二行', $body['content']);
$this->assertSame(2, $body['badge']);
$this->assertSame(['dialog_id' => '41', 'msg_id' => '42'], json_decode($body['payload']['data'], true));
$this->assertArrayNotHasKey('dialog_id', $body['payload']);
$this->assertSame(['importance' => 'NORMAL', 'category' => 'IM'], $body['payload']['huawei']);
$this->assertSame(['classification' => 1], $body['payload']['vivo']);
$this->assertSame([96001], $client->requests[1]['body']['target']['device_ids']);
$this->assertSame(0, $client->requests[1]['body']['badge']);
}
public function test_title_fallback_empty_targets_and_configuration_guards(): void
{
$this->device(910003, '96002');
$client = new RecordingDooPushClient();
foreach ([['doopush' => 'close'], ['doopush_app_id' => ''], ['doopush_app_secret' => '']] as $settings) {
$this->settings($settings);
$client->pushToUsers(910003, ['body' => '内容']);
}
$this->settings(['doopush_base_url' => 'https://push.test/api/v1///']);
$client->pushToUsers([], ['body' => '内容']);
$client->pushToUsers(910003, ['title' => "\n", 'body' => "\r\n"]);
$this->assertCount(0, $client->requests);
$client->pushToUsers(910003, ['body' => "内容\n"]);
$this->assertCount(1, $client->requests);
$this->assertSame('内容', $client->requests[0]['body']['title']);
$this->assertSame('https://push.test/api/v1/apps/123/push', $client->requests[0]['url']);
}
public function test_device_ids_cannot_overflow_into_a_different_target(): void
{
$this->device(910007, str_repeat('9', 64));
$this->device(910007, '00096006');
$client = new RecordingDooPushClient();
$client->pushToUsers(910007, ['body' => '消息']);
$this->assertCount(1, $client->requests);
$this->assertSame([96006], $client->requests[0]['body']['target']['device_ids']);
}
public function test_failures_retry_at_most_twice_and_do_not_block_the_next_user(): void
{
$this->device(910004, '96003');
$this->device(910005, '96004');
$client = new RecordingDooPushClient([
new \RuntimeException('Connection timeout'),
['ret' => 1, 'msg' => '200', 'data' => 'not-json'],
['ret' => 1, 'msg' => '400', 'data' => '{"code":400,"message":"Invalid device"}'],
]);
$client->pushToUsers([910004, 910005], ['body' => '消息']);
$this->assertCount(4, $client->requests);
$this->assertSame([96003], $client->requests[2]['body']['target']['device_ids']);
$this->assertSame([96004], $client->requests[3]['body']['target']['device_ids']);
$client = new RecordingDooPushClient([['ret' => 0, 'msg' => 'Timeout', 'data' => []]]);
$client->pushToUsers(910004, ['body' => '消息']);
$this->assertCount(2, $client->requests);
}
public function test_doopush_only_task_keeps_pc_delay_and_read_skip_behavior(): void
{
$userid = 910006;
$this->device($userid, '96005');
$client = new RecordingDooPushClient();
$this->app->instance(DooPushClient::class, $client);
$this->assertTrue(AppPush::enabled());
$msg = ['body' => '消息', 'extra' => ['msg_id' => 98001]];
Cache::put("user_pc_active:{$userid}", time(), 60);
(new PushUmengMsg($userid, $msg))->start();
$this->assertCount(0, $client->requests);
Cache::forget("user_pc_active:{$userid}");
(new PushUmengMsg($userid, $msg))->start();
$this->assertCount(1, $client->requests);
WebSocketDialogMsgRead::createInstance(['userid' => $userid, 'msg_id' => 98001, 'read_at' => now()])->save();
$delayed = new PushUmengMsg($userid, $msg);
$delayed->delay(10);
$delayed->start();
$this->assertCount(1, $client->requests);
$this->settings(['doopush' => 'close']);
$this->assertFalse(AppPush::enabled());
}
}
/** 用可控响应替换外部网络,验证实际数据库选出的发送目标与载荷。 */
class RecordingDooPushClient extends DooPushClient
{
public array $requests = [];
public function __construct(private array $responses = []) {}
protected function request(string $url, array $headers, array $body): array
{
$this->requests[] = compact('url', 'headers', 'body');
$response = array_shift($this->responses);
if ($response instanceof \Throwable) {
throw $response;
}
return $response ?? ['ret' => 1, 'msg' => '200', 'data' => '{"code":200,"data":[]}'];
}
}