dootask/tests/Feature/AppPushTest.php
kuaifan 531129f48d feat(push): 移除友盟推送,APP 推送改为系统配置 / 自定义 / 关闭三档
- 删除友盟别名接口、模型、日志清理及 hedeqiang/umeng 依赖,迁移删除 umeng_alias / umeng_logs 表
- 系统配置(默认):通过官方 DooPush 共享通道自助注册租户,按 App 上报的推送 Token 绑定设备
- 自定义:沿用自有 DooPush 应用凭证;关闭:不推送也不记录设备
- 老站点升级后统一为系统配置,原自定义凭证保留
- PushUmengMsg 更名为 PushAppMsg;同步语言包、API 对照表与 ai-kb
2026-10-19 15:46:16 +00:00

413 lines
20 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\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\PushAppMsg;
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([
'doopush' => 'custom',
'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;
}
public function test_get_and_post_update_transfer_device(): void
{
[$first, $token] = $this->userToken();
$data = [
'device_id' => '94001', 'vendor' => 'apns',
'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);
[$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_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_device_bindings(): void
{
$token = 'logout-test-token';
$row = $this->device(1, '94006', ['device_hash' => md5($token)]);
$other = $this->device(1, '94007');
UserDevice::forget($token);
$this->assertNull($row->fresh());
$this->assertNotNull($other->fresh());
}
public function test_settings_default_and_save_filter_unknown_keys(): void
{
Base::setting('appPushSetting', []);
[, $token] = $this->userToken(true);
$this->getJson('/api/system/setting/apppush', ['token' => $token])
->assertJsonPath('ret', 1)->assertJsonPath('data.doopush', 'system');
config(['dootask.system_setting' => 'enabled']);
$this->postJson('/api/system/setting/apppush', [
'type' => 'save', 'push' => 'open', 'ios_key' => 'old-key',
'doopush' => 'custom', 'doopush_app_id' => '123', 'doopush_app_secret' => 'new-secret',
'doopush_base_url' => 'https://push.test/api/v1/', 'unexpected' => 'ignored',
], ['token' => $token])->assertJsonPath('ret', 1)
->assertJsonMissingPath('data.push')->assertJsonMissingPath('data.ios_key')
->assertJsonPath('data.doopush', 'custom')->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 PushAppMsg($userid, $msg))->start();
$this->assertCount(0, $client->requests);
Cache::forget("user_pc_active:{$userid}");
(new PushAppMsg($userid, $msg))->start();
$this->assertCount(1, $client->requests);
WebSocketDialogMsgRead::createInstance(['userid' => $userid, 'msg_id' => 98001, 'read_at' => now()])->save();
$delayed = new PushAppMsg($userid, $msg);
$delayed->delay(10);
$delayed->start();
$this->assertCount(1, $client->requests);
$this->settings(['doopush' => 'close']);
$this->assertFalse(AppPush::enabled());
}
public function test_settings_reject_unknown_mode(): void
{
[, $token] = $this->userToken(true);
config(['dootask.system_setting' => 'enabled']);
$this->postJson('/api/system/setting/apppush', ['type' => 'save', 'doopush' => 'open'], ['token' => $token])
->assertJsonPath('ret', 0);
}
private function systemMode(?string $secret = null): RecordingDooPushClient
{
$this->settings(['doopush' => 'system']);
config(['dootask.doopush_system' => ['base_url' => 'https://push.test/api/v1', 'app_id' => '10', 'app_key' => 'dp_ak_test']]);
Base::setting('appPushTenant', $secret ? [
'base_url' => 'https://push.test/api/v1', 'app_id' => '10', 'tenant_id' => 1, 'tenant_secret' => $secret,
] : []);
$client = new RecordingDooPushClient();
$this->app->instance(DooPushClient::class, $client);
return $client;
}
private static function reply(int $status, array $json): array
{
return ['ret' => 1, 'msg' => (string)$status, 'data' => json_encode($json)];
}
public function test_system_mode_registers_tenant_and_binds_by_push_token(): void
{
$client = $this->systemMode();
[$user, $token] = $this->userToken();
$data = ['device_id' => '94101', 'vendor' => 'apns', 'is_notified' => 1];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 0);
$this->assertCount(0, $client->requests);
$client->responses = [
self::reply(201, ['code' => 201, 'data' => ['tenant_id' => 7, 'tenant_secret' => 'dp_ts_new']]),
self::reply(200, ['code' => 200, 'data' => ['device_id' => 77001]]),
];
$this->postJson('/api/push/device', $data + ['push_token' => 'apns-token'], ['token' => $token, 'platform' => 'ios'])
->assertJsonPath('ret', 1);
$this->assertSame('https://push.test/api/v1/apps/10/tenants', $client->requests[0]['url']);
$this->assertSame('dp_ak_test', $client->requests[0]['headers']['X-App-Key']);
$this->assertSame('https://push.test/api/v1/apps/10/tenant/devices', $client->requests[1]['url']);
$this->assertSame('Bearer dp_ts_new', $client->requests[1]['headers']['Authorization']);
$this->assertSame(['token' => 'apns-token'], $client->requests[1]['body']);
// 以 DooPush 返回的设备ID为准,不信任客户端自报。
$row = AppPushDevice::whereChannel(AppPush::CHANNEL_SYSTEM)->sole();
$this->assertSame('77001', $row->device_id);
$this->assertSame($user->userid, $row->userid);
$this->assertFalse(AppPushDevice::whereDeviceId('94101')->exists());
}
public function test_system_mode_reregisters_on_401_but_not_on_403(): void
{
$client = $this->systemMode('dp_ts_old');
[, $token] = $this->userToken();
$data = ['device_id' => '94102', 'vendor' => 'apns', 'push_token' => 'apns-token'];
$client->responses = [
self::reply(401, ['code' => 401, 'message' => 'invalid']),
self::reply(201, ['code' => 201, 'data' => ['tenant_id' => 8, 'tenant_secret' => 'dp_ts_renew']]),
self::reply(200, ['code' => 200, 'data' => ['device_id' => 77002]]),
];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 1);
$this->assertCount(3, $client->requests);
$this->assertSame('Bearer dp_ts_renew', $client->requests[2]['headers']['Authorization']);
$client->requests = [];
$client->responses = [self::reply(403, ['code' => 403, 'message' => 'disabled'])];
$this->postJson('/api/push/device', $data, ['token' => $token, 'platform' => 'ios'])->assertJsonPath('ret', 0);
$this->assertCount(1, $client->requests);
$this->assertSame('dp_ts_renew', Base::setting('appPushTenant')['tenant_secret']);
}
public function test_system_mode_push_cleans_unbound_devices_and_remove_unbinds(): void
{
$client = $this->systemMode('dp_ts_live');
$this->device(910101, '77003', ['channel' => AppPush::CHANNEL_SYSTEM]);
$this->device(910101, '77004', ['channel' => AppPush::CHANNEL_SYSTEM, 'updated_at' => now()->subMinute()]);
$this->device(910101, '96101');
$client->responses = [self::reply(200, ['code' => 200, 'data' => ['count' => 1, 'unbound_device_ids' => [77004]]])];
$client->pushToUsers(910101, ['body' => '消息']);
$this->assertCount(1, $client->requests);
$this->assertSame('https://push.test/api/v1/apps/10/tenant/push', $client->requests[0]['url']);
$this->assertSame('Bearer dp_ts_live', $client->requests[0]['headers']['Authorization']);
$this->assertSame([77003, 77004], $client->requests[0]['body']['device_ids']);
$this->assertArrayNotHasKey('target', $client->requests[0]['body']);
$this->assertFalse(AppPushDevice::whereChannel(AppPush::CHANNEL_SYSTEM)->whereDeviceId('77004')->exists());
// 401 时清除失效凭证,不重试。
$client->requests = [];
$client->responses = [self::reply(401, ['code' => 401, 'message' => 'invalid'])];
$client->pushToUsers(910101, ['body' => '消息']);
$this->assertCount(1, $client->requests);
$this->assertSame([], Base::setting('appPushTenant'));
$client = $this->systemMode('dp_ts_live');
[$user, $token] = $this->userToken();
$this->device($user->userid, '77005', ['channel' => AppPush::CHANNEL_SYSTEM, 'device_hash' => md5($token)]);
$this->postJson('/api/push/device', ['action' => 'remove', 'device_id' => '77005'], ['token' => $token])
->assertJsonPath('ret', 1);
$this->assertFalse(AppPushDevice::whereDeviceId('77005')->exists());
$this->assertSame('DELETE', $client->requests[0]['method']);
$this->assertSame('https://push.test/api/v1/apps/10/tenant/devices/77005', $client->requests[0]['url']);
}
public function test_close_mode_does_not_record_devices(): void
{
$this->settings(['doopush' => 'close']);
[, $token] = $this->userToken();
$this->postJson('/api/push/device', ['device_id' => '94103', 'vendor' => 'apns'], ['token' => $token, 'platform' => 'ios'])
->assertJsonPath('ret', 1);
$this->assertFalse(AppPushDevice::whereDeviceId('94103')->exists());
}
}
/** 用可控响应替换外部网络,验证实际数据库选出的发送目标与载荷。 */
class RecordingDooPushClient extends DooPushClient
{
public array $requests = [];
public function __construct(public array $responses = []) {}
protected function request(string $url, array $headers, array $body, string $method = 'POST'): array
{
$this->requests[] = compact('url', 'headers', 'body', 'method');
$response = array_shift($this->responses);
if ($response instanceof \Throwable) {
throw $response;
}
return $response ?? ['ret' => 1, 'msg' => '200', 'data' => '{"code":200,"data":[]}'];
}
}