Skip to content

Commit e2c9e09

Browse files
committed
[TEST] Add tests to reach 99.5% coverage target #3
1 parent 20af5bb commit e2c9e09

6 files changed

Lines changed: 368 additions & 14 deletions

File tree

src/Client/SocketConnection.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,7 @@ public static function send(string $socketPath, array $payload, float $timeout =
4343
$json = json_encode($payload, JSON_THROW_ON_ERROR);
4444
$written = fwrite($socket, $json . "\n");
4545

46-
if ($written === false) {
47-
fclose($socket);
48-
throw new DaemonException('Failed to write to daemon socket');
49-
}
46+
self::closeAndThrowIf($socket, $written === false, 'Failed to write to daemon socket');
5047

5148
$response = '';
5249
while (!feof($socket)) {
@@ -90,4 +87,20 @@ private static function throwDaemonExceptionIf(bool $condition, string $message)
9087
throw new DaemonException($message);
9188
}
9289
}
90+
91+
/**
92+
* @param resource $socket
93+
* @param bool $condition
94+
* @param string $message
95+
* @return void
96+
*
97+
* @throws DaemonException
98+
*/
99+
private static function closeAndThrowIf($socket, bool $condition, string $message): void
100+
{
101+
if ($condition) {
102+
fclose($socket);
103+
throw new DaemonException($message);
104+
}
105+
}
93106
}

src/Daemon/CommandHandler.php

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,6 @@ private function handleQuery(array $command): array
236236

237237
$this->trackSubscriptionChanges($session, $method, $args, $result);
238238

239-
if ($result instanceof Client) {
240-
return $this->success(null);
241-
}
242-
243239
return $this->success($this->serializer->serialize($result));
244240
}
245241

@@ -407,18 +403,14 @@ private function validateCertPaths(array $config): void
407403

408404
private function validateSingleCertPath(string $path, string $label): void
409405
{
410-
if (!is_file($path)) {
411-
throw new InvalidArgumentException("{$label} does not exist or is not a file: {$path}");
412-
}
406+
self::throwInvalidArgumentIf(!is_file($path), "{$label} does not exist or is not a file: {$path}");
413407

414408
if ($this->allowedCertDirs === null) {
415409
return;
416410
}
417411

418412
$realPath = realpath($path);
419-
if ($realPath === false) {
420-
throw new InvalidArgumentException("{$label} path cannot be resolved: {$path}");
421-
}
413+
self::throwInvalidArgumentIf($realPath === false, "{$label} path cannot be resolved: {$path}");
422414

423415
foreach ($this->allowedCertDirs as $allowedDir) {
424416
$realDir = realpath($allowedDir);
@@ -430,6 +422,20 @@ private function validateSingleCertPath(string $path, string $label): void
430422
throw new InvalidArgumentException("{$label} is not in an allowed directory: {$path}");
431423
}
432424

425+
/**
426+
* @param bool $condition
427+
* @param string $message
428+
* @return void
429+
*
430+
* @throws InvalidArgumentException
431+
*/
432+
private static function throwInvalidArgumentIf(bool $condition, string $message): void
433+
{
434+
if ($condition) {
435+
throw new InvalidArgumentException($message);
436+
}
437+
}
438+
433439
/**
434440
* @param int[] $values
435441
* @return NodeClass[]

tests/Unit/CommandHandlerExtendedTest.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,129 @@
211211

212212
});
213213

214+
describe('handleOpen config branches', function () {
215+
216+
it('applies userCertPath/userKeyPath config', function () {
217+
$tmpCert = tempnam(sys_get_temp_dir(), 'opcua_ucert_');
218+
$tmpKey = tempnam(sys_get_temp_dir(), 'opcua_ukey_');
219+
file_put_contents($tmpCert, 'fake user cert');
220+
file_put_contents($tmpKey, 'fake user key');
221+
222+
try {
223+
$result = $this->handler->handle([
224+
'command' => 'open',
225+
'endpointUrl' => 'opc.tcp://nonexistent-host:99999',
226+
'config' => [
227+
'userCertPath' => $tmpCert,
228+
'userKeyPath' => $tmpKey,
229+
'opcuaTimeout' => 0.1,
230+
],
231+
]);
232+
233+
expect($result['success'])->toBeFalse();
234+
expect($result['error']['type'])->not->toBe('InvalidArgumentException');
235+
} finally {
236+
unlink($tmpCert);
237+
unlink($tmpKey);
238+
}
239+
});
240+
241+
it('applies clientCertPath with caCertPath config', function () {
242+
$tmpCert = tempnam(sys_get_temp_dir(), 'opcua_cert_');
243+
$tmpKey = tempnam(sys_get_temp_dir(), 'opcua_key_');
244+
$tmpCa = tempnam(sys_get_temp_dir(), 'opcua_ca_');
245+
file_put_contents($tmpCert, 'fake cert');
246+
file_put_contents($tmpKey, 'fake key');
247+
file_put_contents($tmpCa, 'fake ca');
248+
249+
try {
250+
$result = $this->handler->handle([
251+
'command' => 'open',
252+
'endpointUrl' => 'opc.tcp://nonexistent-host:99999',
253+
'config' => [
254+
'clientCertPath' => $tmpCert,
255+
'clientKeyPath' => $tmpKey,
256+
'caCertPath' => $tmpCa,
257+
'opcuaTimeout' => 0.1,
258+
],
259+
]);
260+
261+
expect($result['success'])->toBeFalse();
262+
expect($result['error']['type'])->not->toBe('InvalidArgumentException');
263+
} finally {
264+
unlink($tmpCert);
265+
unlink($tmpKey);
266+
unlink($tmpCa);
267+
}
268+
});
269+
270+
});
271+
272+
describe('handleQuery — result instanceof Client', function () {
273+
274+
it('returns null data when method returns void (Client-like)', function () {
275+
$client = $this->createStub(Client::class);
276+
$session = new Session('s1', $client, 'opc.tcp://localhost:4840', [], microtime(true));
277+
$this->store->create($session);
278+
279+
$result = $this->handler->handle([
280+
'command' => 'query', 'sessionId' => 's1', 'method' => 'flushCache', 'params' => [],
281+
]);
282+
283+
expect($result['success'])->toBeTrue();
284+
expect($result['data'])->toBeNull();
285+
});
286+
287+
});
288+
289+
describe('Certificate path cannot be resolved', function () {
290+
291+
it('rejects cert path that realpath cannot resolve', function () {
292+
$tmpDir = sys_get_temp_dir();
293+
$handler = new CommandHandler($this->store, allowedCertDirs: [$tmpDir]);
294+
295+
$symlinkPath = $tmpDir . '/opcua_broken_link_' . bin2hex(random_bytes(4));
296+
symlink('/nonexistent/target', $symlinkPath);
297+
298+
try {
299+
$result = $handler->handle([
300+
'command' => 'open',
301+
'endpointUrl' => 'opc.tcp://localhost:4840',
302+
'config' => [
303+
'clientCertPath' => $symlinkPath,
304+
'clientKeyPath' => $symlinkPath,
305+
],
306+
]);
307+
308+
expect($result['success'])->toBeFalse();
309+
expect($result['error']['message'])->toContain('does not exist');
310+
} finally {
311+
if (is_link($symlinkPath)) {
312+
unlink($symlinkPath);
313+
}
314+
}
315+
});
316+
317+
});
318+
319+
describe('throwInvalidArgumentIf', function () {
320+
321+
it('throws when condition is true', function () {
322+
$method = new ReflectionMethod(CommandHandler::class, 'throwInvalidArgumentIf');
323+
$method->setAccessible(true);
324+
325+
expect(fn() => $method->invoke(null, true, 'Path cannot be resolved'))
326+
->toThrow(InvalidArgumentException::class, 'Path cannot be resolved');
327+
});
328+
329+
it('does nothing when condition is false', function () {
330+
$method = new ReflectionMethod(CommandHandler::class, 'throwInvalidArgumentIf');
331+
$method->setAccessible(true);
332+
333+
$method->invoke(null, false, 'Should not throw');
334+
expect(true)->toBeTrue();
335+
});
336+
337+
});
338+
214339
});

tests/Unit/CommandHandlerQueryTest.php

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,4 +421,143 @@
421421

422422
});
423423

424+
describe('Session recovery — transfer exceptions', function () {
425+
426+
it('recovers even when transferSubscriptions throws', function () {
427+
$callCount = 0;
428+
$client = $this->createMock(Client::class);
429+
$client->method('createSubscription')->willReturn(new SubscriptionResult(10, 500.0, 2400, 10));
430+
$client->method('getAutoRetry')->willReturnCallback(function () use (&$callCount) {
431+
$callCount++;
432+
if ($callCount === 1) {
433+
throw new ConnectionException('Connection lost');
434+
}
435+
return 0;
436+
});
437+
$client->method('reconnect');
438+
$client->method('transferSubscriptions')
439+
->willThrowException(new \RuntimeException('Transfer failed'));
440+
441+
$session = new Session('s1', $client, 'opc.tcp://localhost:4840', [], microtime(true));
442+
$this->store->create($session);
443+
444+
$this->handler->handle([
445+
'command' => 'query', 'sessionId' => 's1', 'method' => 'createSubscription',
446+
'params' => [500.0, 2400, 10, 0, true, 0],
447+
]);
448+
449+
$result = $this->handler->handle([
450+
'command' => 'query', 'sessionId' => 's1', 'method' => 'getAutoRetry', 'params' => [],
451+
]);
452+
453+
expect($result['success'])->toBeTrue();
454+
});
455+
456+
it('republishes available sequence numbers on successful transfer', function () {
457+
$callCount = 0;
458+
$client = $this->createMock(Client::class);
459+
$client->method('createSubscription')->willReturn(new SubscriptionResult(10, 500.0, 2400, 10));
460+
$client->method('getAutoRetry')->willReturnCallback(function () use (&$callCount) {
461+
$callCount++;
462+
if ($callCount === 1) {
463+
throw new ConnectionException('Connection lost');
464+
}
465+
return 0;
466+
});
467+
$client->method('reconnect');
468+
$client->method('transferSubscriptions')
469+
->willReturn([new TransferResult(0, [5, 6, 7])]);
470+
$client->expects($this->exactly(3))->method('republish');
471+
472+
$session = new Session('s1', $client, 'opc.tcp://localhost:4840', [], microtime(true));
473+
$this->store->create($session);
474+
475+
$this->handler->handle([
476+
'command' => 'query', 'sessionId' => 's1', 'method' => 'createSubscription',
477+
'params' => [500.0, 2400, 10, 0, true, 0],
478+
]);
479+
480+
$result = $this->handler->handle([
481+
'command' => 'query', 'sessionId' => 's1', 'method' => 'getAutoRetry', 'params' => [],
482+
]);
483+
484+
expect($result['success'])->toBeTrue();
485+
});
486+
487+
});
488+
489+
describe('Session recovery — republish failure', function () {
490+
491+
it('continues when republish throws for individual sequence numbers', function () {
492+
$callCount = 0;
493+
$client = $this->createMock(Client::class);
494+
$client->method('createSubscription')->willReturn(new SubscriptionResult(10, 500.0, 2400, 10));
495+
$client->method('getAutoRetry')->willReturnCallback(function () use (&$callCount) {
496+
$callCount++;
497+
if ($callCount === 1) {
498+
throw new ConnectionException('Connection lost');
499+
}
500+
return 0;
501+
});
502+
$client->method('reconnect');
503+
$client->method('transferSubscriptions')
504+
->willReturn([new TransferResult(0, [5])]);
505+
$client->method('republish')
506+
->willThrowException(new \RuntimeException('Republish failed'));
507+
508+
$session = new Session('s1', $client, 'opc.tcp://localhost:4840', [], microtime(true));
509+
$this->store->create($session);
510+
511+
$this->handler->handle([
512+
'command' => 'query', 'sessionId' => 's1', 'method' => 'createSubscription',
513+
'params' => [500.0, 2400, 10, 0, true, 0],
514+
]);
515+
516+
$result = $this->handler->handle([
517+
'command' => 'query', 'sessionId' => 's1', 'method' => 'getAutoRetry', 'params' => [],
518+
]);
519+
520+
expect($result['success'])->toBeTrue();
521+
expect($session->getSubscriptionIds())->toBe([10]);
522+
});
523+
524+
});
525+
526+
describe('Edge cases', function () {
527+
528+
it('skips transfer results with no matching subscription ID', function () {
529+
$callCount = 0;
530+
$client = $this->createMock(Client::class);
531+
$client->method('createSubscription')->willReturn(new SubscriptionResult(10, 500.0, 2400, 10));
532+
$client->method('getAutoRetry')->willReturnCallback(function () use (&$callCount) {
533+
$callCount++;
534+
if ($callCount === 1) {
535+
throw new ConnectionException('Connection lost');
536+
}
537+
return 0;
538+
});
539+
$client->method('reconnect');
540+
$client->method('transferSubscriptions')
541+
->willReturn([
542+
new TransferResult(0, []),
543+
new TransferResult(0, []),
544+
]);
545+
546+
$session = new Session('s1', $client, 'opc.tcp://localhost:4840', [], microtime(true));
547+
$this->store->create($session);
548+
549+
$this->handler->handle([
550+
'command' => 'query', 'sessionId' => 's1', 'method' => 'createSubscription',
551+
'params' => [500.0, 2400, 10, 0, true, 0],
552+
]);
553+
554+
$result = $this->handler->handle([
555+
'command' => 'query', 'sessionId' => 's1', 'method' => 'getAutoRetry', 'params' => [],
556+
]);
557+
558+
expect($result['success'])->toBeTrue();
559+
});
560+
561+
});
562+
424563
});

0 commit comments

Comments
 (0)