1: <?php declare(strict_types=1);
2:
3: /**
4: * Clansuite Server Query
5: *
6: * SPDX-FileCopyrightText: 2003-2025 Jens A. Koch
7: * SPDX-License-Identifier: MIT
8: *
9: * For the full copyright and license information, please view
10: * the LICENSE file that was distributed with this source code.
11: */
12:
13: namespace Clansuite\ServerQuery\Util;
14:
15: use function base64_decode;
16: use function count;
17: use function file_exists;
18: use function file_get_contents;
19: use function is_array;
20: use function is_string;
21: use function json_decode;
22: use function microtime;
23: use Exception;
24: use Override;
25:
26: /**
27: * Mock UDP Client for replay-based testing using captured fixtures.
28: */
29: final class MockUdpClient extends UdpClient
30: {
31: /**
32: * @var array<array<string, mixed>>
33: */
34: private array $captures = [];
35: private int $responseIndex = 0;
36:
37: /**
38: * Load responses from a JSON fixture file.
39: */
40: public function loadFixture(string $jsonFile): bool
41: {
42: if (!file_exists($jsonFile)) {
43: return false;
44: }
45:
46: $c = file_get_contents($jsonFile);
47:
48: if ($c === false) {
49: throw new Exception('Could not read fixture file: ' . $jsonFile);
50: }
51:
52: $metadata = json_decode($c, true);
53:
54: if (!is_array($metadata) || !isset($metadata['captures']) || !is_array($metadata['captures'])) {
55: return false;
56: }
57:
58: $this->captures = [];
59:
60: foreach ($metadata['captures'] as $capture) {
61: if (!is_array($capture)) {
62: continue;
63: }
64:
65: $sent = null;
66:
67: if (isset($capture['sent']) && is_string($capture['sent'])) {
68: $sent = base64_decode($capture['sent'], true);
69:
70: if ($sent === false) {
71: $sent = null;
72: }
73: }
74:
75: $received = null;
76:
77: if (isset($capture['received']) && is_string($capture['received'])) {
78: $received = base64_decode($capture['received'], true);
79:
80: if ($received === false) {
81: $received = null;
82: }
83: }
84:
85: $this->captures[] = [
86: 'sent' => $sent,
87: 'received' => $received,
88: 'timestamp' => $capture['timestamp'] ?? microtime(true),
89: ];
90: }
91:
92: $this->responseIndex = 0;
93:
94: return $this->captures !== [];
95: }
96:
97: /**
98: * Mock query method that returns pre-recorded responses based on request matching.
99: */
100: #[Override]
101: public function query(string $address, int $port, string $packet): ?string
102: {
103: // If we have captures with sent packets, try to match the request
104: if ($this->responseIndex < count($this->captures)) {
105: $capture = $this->captures[$this->responseIndex] ?? [];
106:
107: // If the capture has a sent packet, check if it matches (or is empty)
108: $sentExists = isset($capture['sent']) && is_string($capture['sent']);
109:
110: if ($sentExists && ($capture['sent'] === '' || $capture['sent'] === $packet)) {
111: $response = $capture['received'] ?? null;
112: $this->responseIndex++;
113:
114: return is_string($response) ? $response : null;
115: }
116: }
117:
118: // Fallback: return responses in sequence regardless of request matching
119: if ($this->responseIndex < count($this->captures)) {
120: $response = $this->captures[$this->responseIndex]['received'] ?? null;
121: $this->responseIndex++;
122:
123: return is_string($response) ? $response : null;
124: }
125:
126: return null; // No more responses
127: }
128:
129: /**
130: * Reset the mock for reuse.
131: */
132: public function reset(): void
133: {
134: $this->responseIndex = 0;
135: }
136:
137: /**
138: * Get the number of available captures.
139: */
140: public function getCaptureCount(): int
141: {
142: return count($this->captures);
143: }
144: }
145: