id = 77; $job->type = 'network_discovery'; $job->status = 'pending'; $job->networkRangeId = 5; $range = new NetworkRange(); $range->id = 5; $range->cidr = '192.168.1.1/32'; $jobs = new InMemoryScanJobRepository([$job]); $ranges = new InMemoryNetworkRangeLookup([5 => $range]); $scanner = new RecordingNetworkScanner(); $runner = new ScanJobRunner($jobs, $scanner, $ranges); $runner->runNext(); self::assertSame('done', $job->status); self::assertSame($range, $scanner->scannedRange); self::assertSame(77, $scanner->scanJobId); } public function testPausedDiscoveryJobKeepsProgressAndDoesNotBecomeDone(): void { $job = new ScanJob(); $job->id = 88; $job->type = 'network_discovery'; $job->status = 'pending'; $job->networkRangeId = 5; $job->resultJson = json_encode([ 'next_ip_index' => 1, 'hosts_found' => 2, ]); $range = new NetworkRange(); $range->id = 5; $range->cidr = '192.168.1.0/30'; $jobs = new InMemoryScanJobRepository([$job]); $ranges = new InMemoryNetworkRangeLookup([5 => $range]); $scanner = new RecordingNetworkScanner(); $scanner->result = [ 'hosts' => [], 'completed' => false, 'next_ip_index' => 2, 'total_ips' => 2, 'scanned_count' => 1, 'hosts_found' => 2, ]; $jobs->statusAfterFirstProgressCheck = 'paused'; $runner = new ScanJobRunner($jobs, $scanner, $ranges); $runner->runNext(); self::assertSame('paused', $job->status); self::assertNull($job->finishedAt); self::assertSame(1, $scanner->startIndex); self::assertSame(2, json_decode($job->resultJson ?? '{}', true)['next_ip_index']); } } final class InMemoryScanJobRepository extends ScanJobRepository { public ?string $statusAfterFirstProgressCheck = null; private int $findByIdCalls = 0; /** @param ScanJob[] $pending */ public function __construct(private array $pending) { } public function findPending(): array { return $this->pending; } public function save(ScanJob $job): void { } public function findById(int $id): ?ScanJob { $this->findByIdCalls++; if ($this->statusAfterFirstProgressCheck !== null && $this->findByIdCalls > 1) { $this->pending[0]->status = $this->statusAfterFirstProgressCheck; } return $this->pending[0] ?? null; } } final class InMemoryNetworkRangeLookup extends NetworkRangeRepository { /** @param array $ranges */ public function __construct(private array $ranges) { } public function findById(int $id): ?NetworkRange { return $this->ranges[$id] ?? null; } } final class RecordingNetworkScanner extends NetworkScanner { public ?NetworkRange $scannedRange = null; public ?int $scanJobId = null; public ?int $startIndex = null; public array $result = [ 'hosts' => [], 'completed' => true, 'next_ip_index' => 1, 'total_ips' => 1, 'scanned_count' => 1, 'hosts_found' => 0, ]; public function __construct() { } public function scan(NetworkRange $range, ?int $scanJobId = null): array { $this->scannedRange = $range; $this->scanJobId = $scanJobId; return []; } public function scanResumable( NetworkRange $range, int $scanJobId, int $startIndex, callable $shouldStop, callable $onProgress ): array { $this->scannedRange = $range; $this->scanJobId = $scanJobId; $this->startIndex = $startIndex; $onProgress($this->result); return $this->result; } }