444 lines
16 KiB
PHP
444 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Jobs\AutoTransferAddress;
|
|
use App\Models\Channel;
|
|
use App\Models\Device;
|
|
use App\Models\User;
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\AutoTransferConfig;
|
|
use App\Services\AutoTransferService;
|
|
use App\Services\TransferService;
|
|
use App\Services\WalletBalanceService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Mockery;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class AutoTransferTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function makeOfficialDevice(): Device
|
|
{
|
|
$ch = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
Channel::query()->create([
|
|
'channel_id' => $ch,
|
|
'user_id' => Channel::OFFICIAL_USER_ID,
|
|
'status' => 1,
|
|
]);
|
|
|
|
return Device::query()->create([
|
|
'device_id' => 'dev-official-auto',
|
|
'channel_id' => $ch,
|
|
]);
|
|
}
|
|
|
|
private function makeAgentDevice(User $agent): Device
|
|
{
|
|
$ch = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
|
Channel::query()->create([
|
|
'channel_id' => $ch,
|
|
'user_id' => $agent->id,
|
|
'status' => 1,
|
|
]);
|
|
|
|
return Device::query()->create([
|
|
'device_id' => 'dev-agent-auto',
|
|
'channel_id' => $ch,
|
|
]);
|
|
}
|
|
|
|
private function linkAddress(Device $device, array $attrs = []): WalletAddress
|
|
{
|
|
$mn = new WalletMnemonic([
|
|
'device_id' => $device->id,
|
|
'source' => 'imToken',
|
|
]);
|
|
$mn->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
$mn->save();
|
|
|
|
return WalletAddress::query()->create(array_merge([
|
|
'device_id' => $device->id,
|
|
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'mnemonic_id' => $mn->id,
|
|
'derive_index' => 0,
|
|
'usdt' => 10,
|
|
'trx' => 5,
|
|
], $attrs));
|
|
}
|
|
|
|
#[Test]
|
|
public function official_uses_system_switch_only(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device);
|
|
|
|
$cfg = app(AutoTransferConfig::class);
|
|
$this->assertTrue($cfg->isEnabledForAddress($addr));
|
|
|
|
config(['coruna.auto_transfer.enabled' => false]);
|
|
$this->assertFalse(app(AutoTransferConfig::class)->isEnabledForAddress($addr));
|
|
}
|
|
|
|
#[Test]
|
|
public function agent_switch_independent_of_system(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => false]);
|
|
$agent = User::query()->create([
|
|
'username' => 'agent-auto',
|
|
'password' => 'secret12',
|
|
'status' => 1,
|
|
'auto_transfer_enabled' => true,
|
|
]);
|
|
$device = $this->makeAgentDevice($agent);
|
|
$addr = $this->linkAddress($device);
|
|
|
|
$cfg = app(AutoTransferConfig::class);
|
|
$this->assertTrue($cfg->isEnabledForAddress($addr));
|
|
|
|
$agent->auto_transfer_enabled = false;
|
|
$agent->save();
|
|
$this->assertFalse($cfg->isEnabledForAddress($addr->fresh()));
|
|
}
|
|
|
|
#[Test]
|
|
public function threshold_falls_back_to_zero_when_unset(): void
|
|
{
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device);
|
|
$cfg = app(AutoTransferConfig::class);
|
|
$this->assertSame('0', $cfg->thresholdForAddress($addr, 'USDT'));
|
|
}
|
|
|
|
#[Test]
|
|
public function evaluate_calls_transfer_when_above_threshold(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldReceive('handle')
|
|
->once()
|
|
->with('tron', $addr->address, null, 'USDT', 'auto')
|
|
->andReturn(['ok' => true, 'txid' => 'x', 'from' => $addr->address, 'to' => 'T', 'amount' => '12.5', 'asset' => 'USDT']);
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$balances = Mockery::mock(WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->twice()->andReturn(true);
|
|
$this->app->instance(WalletBalanceService::class, $balances);
|
|
|
|
app(AutoTransferService::class)->evaluate($addr, 'test');
|
|
}
|
|
|
|
#[Test]
|
|
public function evaluate_skips_when_live_balance_below_threshold(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldNotReceive('handle');
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$balances = Mockery::mock(WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) {
|
|
WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']);
|
|
$row->refresh();
|
|
|
|
return true;
|
|
});
|
|
$this->app->instance(WalletBalanceService::class, $balances);
|
|
|
|
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
|
$this->assertSame(0, $stats['triggered']);
|
|
$this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6));
|
|
}
|
|
|
|
#[Test]
|
|
public function skips_same_address_asset_within_three_minutes_but_refreshes(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]);
|
|
|
|
\App\Models\TransferRecord::query()->create([
|
|
'from_address' => $addr->address,
|
|
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
|
'chain' => 'tron',
|
|
'tx_hash' => 'abc',
|
|
'amount' => '6',
|
|
'type' => \App\Models\TransferRecord::TYPE_OUT,
|
|
'asset' => 'USDT',
|
|
'operator' => 'auto',
|
|
'status' => \App\Models\TransferRecord::STATUS_SUCCESS,
|
|
'error' => null,
|
|
]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldNotReceive('handle');
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$balances = Mockery::mock(WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) {
|
|
WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']);
|
|
$row->refresh();
|
|
|
|
return true;
|
|
});
|
|
$this->app->instance(WalletBalanceService::class, $balances);
|
|
|
|
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
|
$this->assertSame(0, $stats['triggered']);
|
|
$this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6));
|
|
}
|
|
|
|
#[Test]
|
|
public function allows_transfer_again_after_cooldown(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]);
|
|
|
|
$record = \App\Models\TransferRecord::query()->create([
|
|
'from_address' => $addr->address,
|
|
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
|
'chain' => 'tron',
|
|
'tx_hash' => 'abc',
|
|
'amount' => '6',
|
|
'type' => \App\Models\TransferRecord::TYPE_OUT,
|
|
'asset' => 'USDT',
|
|
'operator' => 'auto',
|
|
'status' => \App\Models\TransferRecord::STATUS_SUCCESS,
|
|
'error' => null,
|
|
]);
|
|
\App\Models\TransferRecord::query()->whereKey($record->id)->update([
|
|
'created_at' => now()->subMinutes(4),
|
|
'updated_at' => now()->subMinutes(4),
|
|
]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldReceive('handle')->once()->andReturn([
|
|
'ok' => true,
|
|
'txid' => 'x',
|
|
'from' => $addr->address,
|
|
'to' => 'T',
|
|
'amount' => '6',
|
|
'asset' => 'USDT',
|
|
]);
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$balances = Mockery::mock(WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->twice()->andReturn(true);
|
|
$this->app->instance(WalletBalanceService::class, $balances);
|
|
|
|
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
|
$this->assertSame(1, $stats['triggered']);
|
|
$this->assertSame(1, $stats['ok']);
|
|
}
|
|
|
|
#[Test]
|
|
public function evaluate_skips_without_mnemonic(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'usdt' => 99,
|
|
]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldNotReceive('handle');
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
app(AutoTransferService::class)->evaluate($addr, 'test');
|
|
$this->assertTrue(true);
|
|
}
|
|
|
|
#[Test]
|
|
public function evaluate_stops_remaining_assets_after_failure(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 8]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldReceive('handle')
|
|
->once()
|
|
->with('tron', $addr->address, null, 'USDT', 'auto')
|
|
->andReturn(['ok' => false, 'error' => 'broadcast failed']);
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$balances = Mockery::mock(WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->once()->andReturn(true);
|
|
$this->app->instance(WalletBalanceService::class, $balances);
|
|
|
|
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
|
$this->assertSame(1, $stats['triggered']);
|
|
$this->assertSame(0, $stats['ok']);
|
|
$this->assertSame(1, $stats['failed']);
|
|
}
|
|
|
|
#[Test]
|
|
public function skips_address_asset_after_previous_auto_failure(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
|
|
|
|
\App\Models\TransferRecord::query()->create([
|
|
'from_address' => $addr->address,
|
|
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
|
'chain' => 'tron',
|
|
'tx_hash' => null,
|
|
'amount' => '12.5',
|
|
'type' => \App\Models\TransferRecord::TYPE_OUT,
|
|
'asset' => 'USDT',
|
|
'operator' => 'auto',
|
|
'status' => \App\Models\TransferRecord::STATUS_FAILED,
|
|
'error' => 'broadcast failed',
|
|
]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldNotReceive('handle');
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
|
$this->assertSame(0, $stats['triggered']);
|
|
$this->assertSame(0, $stats['failed']);
|
|
$this->assertGreaterThanOrEqual(1, $stats['skipped']);
|
|
}
|
|
|
|
#[Test]
|
|
public function run_stops_after_first_failed_address(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$first = $this->linkAddress($device, [
|
|
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
|
'usdt' => 8,
|
|
'trx' => 0,
|
|
]);
|
|
$second = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TXYZsecondAutoAddress000000000003',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'mnemonic_id' => $first->mnemonic_id,
|
|
'usdt' => 9,
|
|
'trx' => 0,
|
|
]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldReceive('handle')
|
|
->once()
|
|
->with('tron', $first->address, null, 'USDT', 'auto')
|
|
->andReturn(['ok' => false, 'error' => 'broadcast failed']);
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$balances = Mockery::mock(WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->once()->andReturn(true);
|
|
$this->app->instance(WalletBalanceService::class, $balances);
|
|
|
|
$stats = app(AutoTransferService::class)->run();
|
|
$this->assertSame(1, $stats['inspected']);
|
|
$this->assertSame(1, $stats['failed']);
|
|
$this->assertSame(0, $stats['ok']);
|
|
$this->assertGreaterThan($first->id, $second->id);
|
|
}
|
|
|
|
#[Test]
|
|
public function run_only_scans_mnemonic_addresses_with_balance(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true]);
|
|
$device = $this->makeOfficialDevice();
|
|
$withBalance = $this->linkAddress($device, [
|
|
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
|
'usdt' => 8,
|
|
'trx' => 0,
|
|
]);
|
|
WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TXYZnoBalanceAddress00000000000001',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'mnemonic_id' => $withBalance->mnemonic_id,
|
|
'usdt' => 0,
|
|
'trx' => 0,
|
|
]);
|
|
WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TXYZnoMnemonicAddress000000000002',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'usdt' => 50,
|
|
]);
|
|
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldReceive('handle')
|
|
->once()
|
|
->with('tron', $withBalance->address, null, 'USDT', 'auto')
|
|
->andReturn(['ok' => true, 'txid' => 'x', 'from' => $withBalance->address, 'to' => 'T', 'amount' => '8', 'asset' => 'USDT']);
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$balances = Mockery::mock(WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->twice()->andReturn(true);
|
|
$this->app->instance(WalletBalanceService::class, $balances);
|
|
|
|
$this->assertSame(1, app(AutoTransferService::class)->run()['inspected']);
|
|
}
|
|
|
|
#[Test]
|
|
public function artisan_command_runs_scan(): void
|
|
{
|
|
$this->artisan('coruna:auto-transfer')
|
|
->expectsOutputToContain('inspected=0')
|
|
->assertSuccessful();
|
|
}
|
|
|
|
#[Test]
|
|
public function run_queues_one_job_per_address_when_not_sync(): void
|
|
{
|
|
config(['coruna.auto_transfer.enabled' => true, 'queue.default' => 'redis']);
|
|
$device = $this->makeOfficialDevice();
|
|
$first = $this->linkAddress($device, [
|
|
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
|
'usdt' => 8,
|
|
'trx' => 0,
|
|
]);
|
|
$second = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TXYZsecondAutoAddress000000000003',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'mnemonic_id' => $first->mnemonic_id,
|
|
'usdt' => 9,
|
|
'trx' => 0,
|
|
]);
|
|
|
|
Queue::fake();
|
|
$transfers = Mockery::mock(TransferService::class);
|
|
$transfers->shouldNotReceive('handle');
|
|
$this->app->instance(TransferService::class, $transfers);
|
|
|
|
$stats = app(AutoTransferService::class)->run();
|
|
$this->assertSame(2, $stats['inspected']);
|
|
$this->assertSame(2, $stats['queued']);
|
|
$this->assertSame(0, $stats['triggered']);
|
|
Queue::assertPushed(AutoTransferAddress::class, 2);
|
|
Queue::assertPushed(AutoTransferAddress::class, fn (AutoTransferAddress $job) => $job->walletAddressId === $first->id);
|
|
Queue::assertPushed(AutoTransferAddress::class, fn (AutoTransferAddress $job) => $job->walletAddressId === $second->id);
|
|
}
|
|
}
|