Skip to content

Commit 89f3566

Browse files
committed
Refactor payload property assignment to use setPayloadProperty method for better type coercion
1 parent e800c58 commit 89f3566

4 files changed

Lines changed: 241 additions & 6 deletions

File tree

src/Ocpp/Messages/Call.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ public function __construct(?array $payload = null)
1818

1919
if ($payload !== null) {
2020
foreach ($payload as $key => $value) {
21-
if (property_exists($this, $key)) {
22-
$this->$key = $value;
23-
}
21+
$this->setPayloadProperty($key, $value);
2422
}
2523
}
2624
}

src/Ocpp/Messages/CallResult.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ public function __construct(string $uniqueId, ?array $payload = null)
1212

1313
if ($payload !== null) {
1414
foreach ($payload as $key => $value) {
15-
if (property_exists($this, $key)) {
16-
$this->$key = $value;
17-
}
15+
$this->setPayloadProperty($key, $value);
1816
}
1917
}
2018
}

src/Ocpp/Messages/Message.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@ abstract class Message
88

99
public string $uniqueId;
1010

11+
/**
12+
* Set a payload property value, coercing types when necessary.
13+
*
14+
* When json_decode is used with $assoc=true, JSON objects become PHP arrays.
15+
* This method uses reflection to detect when a property is typed as `object`
16+
* or `?object` and casts the array value to stdClass before assignment,
17+
* preventing TypeError on PHP 8.2+.
18+
*/
19+
protected function setPayloadProperty(string $key, mixed $value): void
20+
{
21+
if (!property_exists($this, $key)) {
22+
return;
23+
}
24+
25+
// If the value is an array and the property is typed as object/?object, cast it.
26+
if (is_array($value)) {
27+
$reflection = new \ReflectionProperty($this, $key);
28+
$type = $reflection->getType();
29+
30+
if ($type instanceof \ReflectionNamedType && $type->getName() === 'object') {
31+
$value = (object) $value;
32+
}
33+
}
34+
35+
$this->$key = $value;
36+
}
37+
1138
public function getPayload(): object
1239
{
1340
$payload = (array) $this;
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PHPUnit\Framework\TestCase;
6+
use SolutionForest\OcppPhp\Ocpp\v16\CallResults\GetCompositeSchedule;
7+
use SolutionForest\OcppPhp\Ocpp\v16\CallResults\StartTransaction;
8+
use SolutionForest\OcppPhp\Ocpp\v16\CallResults\StopTransaction;
9+
use SolutionForest\OcppPhp\Ocpp\v16\CallResults\Authorize;
10+
use SolutionForest\OcppPhp\Ocpp\v201\CallResults\BootNotification as BootNotificationResult;
11+
use SolutionForest\OcppPhp\Ocpp\v201\Calls\BootNotification as BootNotificationCall;
12+
13+
final class CallResultObjectCoercionTest extends TestCase
14+
{
15+
/**
16+
* Exact payload from the GitHub issue: a real station sending GetCompositeSchedule response.
17+
*/
18+
public function test_get_composite_schedule_with_real_station_payload(): void
19+
{
20+
$payload = [
21+
'status' => 'Accepted',
22+
'connectorId' => 1,
23+
'scheduleStart' => '2026-05-23T09:35:35Z',
24+
'chargingSchedule' => [
25+
'duration' => 7200,
26+
'startSchedule' => '2026-05-23T09:35:35Z',
27+
'chargingRateUnit' => 'A',
28+
'chargingSchedulePeriod' => [
29+
[
30+
'startPeriod' => 0,
31+
'limit' => -1,
32+
'numberPhases' => -1,
33+
],
34+
],
35+
],
36+
];
37+
38+
$result = new GetCompositeSchedule('test-id', $payload);
39+
40+
$this->assertSame('Accepted', $result->status);
41+
$this->assertSame(1, $result->connectorId);
42+
$this->assertSame('2026-05-23T09:35:35Z', $result->scheduleStart);
43+
44+
// The key assertion: chargingSchedule should be an object, not an array
45+
$this->assertIsObject($result->chargingSchedule);
46+
$this->assertInstanceOf(\stdClass::class, $result->chargingSchedule);
47+
48+
// Nested properties should be accessible as object properties
49+
$this->assertSame(7200, $result->chargingSchedule->duration);
50+
$this->assertSame('A', $result->chargingSchedule->chargingRateUnit);
51+
52+
// Nested arrays (like chargingSchedulePeriod) should remain arrays
53+
$this->assertIsArray($result->chargingSchedule->chargingSchedulePeriod);
54+
$this->assertCount(1, $result->chargingSchedule->chargingSchedulePeriod);
55+
$this->assertSame(0, $result->chargingSchedule->chargingSchedulePeriod[0]['startPeriod']);
56+
$this->assertSame(-1, $result->chargingSchedule->chargingSchedulePeriod[0]['limit']);
57+
}
58+
59+
public function test_start_transaction_object_coercion(): void
60+
{
61+
$payload = [
62+
'idTagInfo' => [
63+
'status' => 'Accepted',
64+
'expiryDate' => '2026-05-24T00:00:00Z',
65+
'parentIdTag' => '12345',
66+
],
67+
'transactionId' => 42,
68+
];
69+
70+
$result = new StartTransaction('test-id', $payload);
71+
72+
$this->assertIsObject($result->idTagInfo);
73+
$this->assertSame('Accepted', $result->idTagInfo->status);
74+
$this->assertSame(42, $result->transactionId);
75+
}
76+
77+
public function test_stop_transaction_nullable_object(): void
78+
{
79+
// With idTagInfo present
80+
$result = new StopTransaction('test-id', [
81+
'idTagInfo' => [
82+
'status' => 'Accepted',
83+
],
84+
]);
85+
86+
$this->assertIsObject($result->idTagInfo);
87+
$this->assertSame('Accepted', $result->idTagInfo->status);
88+
}
89+
90+
public function test_authorize_object_coercion(): void
91+
{
92+
$payload = [
93+
'idTagInfo' => [
94+
'status' => 'Accepted',
95+
],
96+
];
97+
98+
$result = new Authorize('test-id', $payload);
99+
100+
$this->assertIsObject($result->idTagInfo);
101+
$this->assertSame('Accepted', $result->idTagInfo->status);
102+
}
103+
104+
public function test_null_is_preserved_for_nullable_object_properties(): void
105+
{
106+
$result = new GetCompositeSchedule('test-id', [
107+
'status' => 'Accepted',
108+
'connectorId' => null,
109+
'scheduleStart' => null,
110+
'chargingSchedule' => null,
111+
]);
112+
113+
$this->assertNull($result->chargingSchedule);
114+
$this->assertNull($result->connectorId);
115+
$this->assertNull($result->scheduleStart);
116+
}
117+
118+
public function test_scalar_properties_are_unaffected(): void
119+
{
120+
$payload = [
121+
'status' => 'Rejected',
122+
'connectorId' => 2,
123+
'scheduleStart' => '2026-06-01T00:00:00Z',
124+
'chargingSchedule' => [
125+
'duration' => 3600,
126+
'startSchedule' => '2026-06-01T00:00:00Z',
127+
'chargingRateUnit' => 'W',
128+
'chargingSchedulePeriod' => [],
129+
],
130+
];
131+
132+
$result = new GetCompositeSchedule('test-id', $payload);
133+
134+
// Scalar properties unchanged
135+
$this->assertIsString($result->status);
136+
$this->assertIsInt($result->connectorId);
137+
$this->assertIsString($result->scheduleStart);
138+
}
139+
140+
public function test_from_array_roundtrip_with_object_properties(): void
141+
{
142+
$message = [
143+
3,
144+
'4202995311',
145+
[
146+
'status' => 'Accepted',
147+
'connectorId' => 1,
148+
'scheduleStart' => '2026-05-23T09:35:35Z',
149+
'chargingSchedule' => [
150+
'duration' => 7200,
151+
'startSchedule' => '2026-05-23T09:35:35Z',
152+
'chargingRateUnit' => 'A',
153+
'chargingSchedulePeriod' => [
154+
[
155+
'startPeriod' => 0,
156+
'limit' => -1,
157+
'numberPhases' => -1,
158+
],
159+
],
160+
],
161+
],
162+
];
163+
164+
$result = GetCompositeSchedule::fromArray($message, 'GetCompositeSchedule', 'v1.6');
165+
166+
$this->assertInstanceOf(GetCompositeSchedule::class, $result);
167+
$this->assertIsObject($result->chargingSchedule);
168+
$this->assertSame('Accepted', $result->chargingSchedule->duration === 7200 ? 'Accepted' : null);
169+
$this->assertSame(7200, $result->chargingSchedule->duration);
170+
171+
// toArray should work without errors
172+
$array = $result->toArray();
173+
$this->assertIsArray($array);
174+
$this->assertSame(3, $array[0]);
175+
$this->assertSame('4202995311', $array[1]);
176+
}
177+
178+
public function test_v201_boot_notification_call_object_coercion(): void
179+
{
180+
$payload = [
181+
'chargingStation' => [
182+
'model' => 'TestModel',
183+
'vendorName' => 'TestVendor',
184+
],
185+
'reason' => 'PowerUp',
186+
];
187+
188+
$call = new BootNotificationCall($payload);
189+
190+
$this->assertIsObject($call->chargingStation);
191+
$this->assertSame('TestModel', $call->chargingStation->model);
192+
$this->assertSame('TestVendor', $call->chargingStation->vendorName);
193+
}
194+
195+
public function test_v201_boot_notification_result_object_coercion(): void
196+
{
197+
$payload = [
198+
'currentTime' => '2026-05-23T09:35:35Z',
199+
'interval' => 300,
200+
'status' => 'Accepted',
201+
'statusInfo' => [
202+
'reasonCode' => 'test-reason',
203+
'additionalInfo' => 'test-info',
204+
],
205+
];
206+
207+
$result = new BootNotificationResult('test-id', $payload);
208+
209+
$this->assertIsObject($result->statusInfo);
210+
$this->assertSame('test-reason', $result->statusInfo->reasonCode);
211+
}
212+
}

0 commit comments

Comments
 (0)