From 8eed517fd38cb291673846875f9aaa7defa28221 Mon Sep 17 00:00:00 2001 From: Maarten Kuiper Date: Thu, 23 Oct 2025 19:39:01 +0200 Subject: [PATCH 1/4] Move all entities to namespaced folders --- src/API/Responses/Resource/ResourceGroupResponse.php | 2 +- src/API/Responses/Visitor/VisitorResponse.php | 2 +- src/API/Responses/Visitor/VisitorsResponse.php | 2 +- src/Client/Client.php | 9 +++++++++ src/Entities/{ => DoorGroups}/DoorEntity.php | 2 +- src/Entities/{ => DoorGroups}/DoorGroupEntity.php | 2 +- src/Entities/{ => DoorGroups}/ResourceTopologyEntity.php | 2 +- src/Entities/{ => Visitor}/ResourceEntity.php | 2 +- src/Entities/{ => Visitor}/VisitorEntity.php | 2 +- tests/API/VisitorClientTest.php | 2 +- tests/Entities/ResourceEntityTest.php | 2 +- 11 files changed, 19 insertions(+), 10 deletions(-) rename src/Entities/{ => DoorGroups}/DoorEntity.php (92%) rename src/Entities/{ => DoorGroups}/DoorGroupEntity.php (94%) rename src/Entities/{ => DoorGroups}/ResourceTopologyEntity.php (94%) rename src/Entities/{ => Visitor}/ResourceEntity.php (94%) rename src/Entities/{ => Visitor}/VisitorEntity.php (97%) diff --git a/src/API/Responses/Resource/ResourceGroupResponse.php b/src/API/Responses/Resource/ResourceGroupResponse.php index bf72472..46e50a0 100644 --- a/src/API/Responses/Resource/ResourceGroupResponse.php +++ b/src/API/Responses/Resource/ResourceGroupResponse.php @@ -4,7 +4,7 @@ use Illuminate\Support\Collection; use Uxicodev\UnifiAccessApi\API\Responses\UnifiResponse; -use Uxicodev\UnifiAccessApi\Entities\DoorGroupEntity; +use Uxicodev\UnifiAccessApi\Entities\DoorGroups\DoorGroupEntity; readonly class ResourceGroupResponse extends UnifiResponse { diff --git a/src/API/Responses/Visitor/VisitorResponse.php b/src/API/Responses/Visitor/VisitorResponse.php index b1c8342..f721fba 100644 --- a/src/API/Responses/Visitor/VisitorResponse.php +++ b/src/API/Responses/Visitor/VisitorResponse.php @@ -3,7 +3,7 @@ namespace Uxicodev\UnifiAccessApi\API\Responses\Visitor; use Uxicodev\UnifiAccessApi\API\Responses\UnifiResponse; -use Uxicodev\UnifiAccessApi\Entities\VisitorEntity; +use Uxicodev\UnifiAccessApi\Entities\Visitor\VisitorEntity; readonly class VisitorResponse extends UnifiResponse { diff --git a/src/API/Responses/Visitor/VisitorsResponse.php b/src/API/Responses/Visitor/VisitorsResponse.php index e4d66ea..bf6ee1c 100644 --- a/src/API/Responses/Visitor/VisitorsResponse.php +++ b/src/API/Responses/Visitor/VisitorsResponse.php @@ -4,7 +4,7 @@ use Illuminate\Support\Collection; use Uxicodev\UnifiAccessApi\API\Responses\UnifiResponse; -use Uxicodev\UnifiAccessApi\Entities\VisitorEntity; +use Uxicodev\UnifiAccessApi\Entities\Visitor\VisitorEntity; readonly class VisitorsResponse extends UnifiResponse { diff --git a/src/Client/Client.php b/src/Client/Client.php index a7bb26b..9eac547 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -8,6 +8,7 @@ use Psr\Http\Message\ResponseInterface; use Uxicodev\UnifiAccessApi\API\CredentialClient; use Uxicodev\UnifiAccessApi\API\DoorGroupsClient; +use Uxicodev\UnifiAccessApi\API\SystemClient; use Uxicodev\UnifiAccessApi\API\VisitorClient; use Uxicodev\UnifiAccessApi\Exceptions\InvalidResponseException; use Uxicodev\UnifiAccessApi\Exceptions\UnifiApiErrorException; @@ -20,11 +21,14 @@ class Client protected DoorGroupsClient $doorGroupsClient; + protected SystemClient $systemClient; + public function __construct(protected GuzzleHttpClient $client) { $this->visitorClient = new VisitorClient($this); $this->credentialClient = new CredentialClient($this); $this->doorGroupsClient = new DoorGroupsClient($this); + $this->systemClient = new SystemClient($this); } /** @@ -138,6 +142,11 @@ public function doorGroups(): DoorGroupsClient return $this->doorGroupsClient; } + public function system(): SystemClient + { + return $this->systemClient; + } + /** * @throws InvalidResponseException * @throws UnifiApiErrorException diff --git a/src/Entities/DoorEntity.php b/src/Entities/DoorGroups/DoorEntity.php similarity index 92% rename from src/Entities/DoorEntity.php rename to src/Entities/DoorGroups/DoorEntity.php index 37603ee..61ab158 100644 --- a/src/Entities/DoorEntity.php +++ b/src/Entities/DoorGroups/DoorEntity.php @@ -1,6 +1,6 @@ Date: Thu, 23 Oct 2025 20:43:31 +0200 Subject: [PATCH 2/4] Add a System client and a method to retrieve System Logs --- src/API/Enums/SystemLogTopic.php | 21 ++++ src/API/Requests/System/SystemLogRequest.php | 102 ++++++++++++++++++ .../Requests/Validators/AbstractValidator.php | 18 ++++ .../Validators/SystemLogValidator.php | 27 +++++ .../Responses/System/SystemLogsResponse.php | 35 ++++++ src/API/SystemClient.php | 30 ++++++ src/Entities/System/SystemLogActorEntity.php | 26 +++++ .../System/SystemLogAuthenticationEntity.php | 20 ++++ src/Entities/System/SystemLogEntity.php | 35 ++++++ src/Entities/System/SystemLogEventEntity.php | 28 +++++ src/Entities/System/SystemLogTargetEntity.php | 26 +++++ .../Validators/SystemLogValidatorTest.php | 66 ++++++++++++ tests/API/SystemClientTest.php | 58 ++++++++++ tests/fixtures/system/logs.json | 43 ++++++++ 14 files changed, 535 insertions(+) create mode 100644 src/API/Enums/SystemLogTopic.php create mode 100644 src/API/Requests/System/SystemLogRequest.php create mode 100644 src/API/Requests/Validators/SystemLogValidator.php create mode 100644 src/API/Responses/System/SystemLogsResponse.php create mode 100644 src/API/SystemClient.php create mode 100644 src/Entities/System/SystemLogActorEntity.php create mode 100644 src/Entities/System/SystemLogAuthenticationEntity.php create mode 100644 src/Entities/System/SystemLogEntity.php create mode 100644 src/Entities/System/SystemLogEventEntity.php create mode 100644 src/Entities/System/SystemLogTargetEntity.php create mode 100644 tests/API/Requests/Validators/SystemLogValidatorTest.php create mode 100644 tests/API/SystemClientTest.php create mode 100644 tests/fixtures/system/logs.json diff --git a/src/API/Enums/SystemLogTopic.php b/src/API/Enums/SystemLogTopic.php new file mode 100644 index 0000000..b2490aa --- /dev/null +++ b/src/API/Enums/SystemLogTopic.php @@ -0,0 +1,21 @@ +toArray()); + } + + /** + * @param array $data + * + * @throws ValidationException + */ + public static function fromArray(array $data): self + { + self::validate($data); + $since = Carbon::parse($data['since']); + $until = isset($data['until']) ? Carbon::parse($data['until']) : null; + + return new self( + SystemLogTopic::from($data['topic']), + $since, + $until, + isset($data['actor_id']) ? new UuidV4($data['actor_id']) : null, + $data['page_size'] ?? null, + $data['page_num'] ?? null + ); + } + + /** + * @param array $data + * + * @throws ValidationException + */ + private static function validate(array $data): void + { + $validator = new SystemLogValidator($data); + if (! $validator->passes()) { + throw new ValidationException( + errors: $validator->getErrors(), + message: 'SystemLogRequest validation failed.' + ); + } + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'topic' => $this->topic->value, + 'since' => $this->since->unix(), + 'until' => $this->until?->unix(), + 'actor_id' => $this->actor_id?->getValue(), + 'page_size' => $this->page_size, + 'page_num' => $this->page_num, + ]; + } + + /** + * @return array + */ + public function getQueryParams(): array + { + return [ + 'page_size' => $this->page_size, + 'page_num' => $this->page_num, + ]; + } + + /** + * @return array + */ + public function getPostBody(): array + { + return [ + 'topic' => $this->topic->value, + 'since' => $this->since->unix(), + 'until' => $this->until?->unix(), + 'actor_id' => $this->actor_id?->getValue(), + ]; + } +} diff --git a/src/API/Requests/Validators/AbstractValidator.php b/src/API/Requests/Validators/AbstractValidator.php index 27ef0c9..9e690af 100644 --- a/src/API/Requests/Validators/AbstractValidator.php +++ b/src/API/Requests/Validators/AbstractValidator.php @@ -10,11 +10,29 @@ abstract class AbstractValidator implements ValidatorContract /** @var array> */ protected array $errors = []; + /** + * @var string[] + */ + protected array $required = []; + /** * @param array $data */ public function __construct(protected readonly array $data) {} + protected function validateRequiredFields(): bool + { + $valid = true; + foreach ($this->required as $field) { + if (! array_key_exists($field, $this->data) || $this->data[$field] === null || $this->data[$field] === '') { + $this->errors[$field][] = "The field '{$field}' is required."; + $valid = false; + } + } + + return $valid; + } + public function getErrors(): MessageBagContract { return new MessageBag($this->errors); diff --git a/src/API/Requests/Validators/SystemLogValidator.php b/src/API/Requests/Validators/SystemLogValidator.php new file mode 100644 index 0000000..3fd9db0 --- /dev/null +++ b/src/API/Requests/Validators/SystemLogValidator.php @@ -0,0 +1,27 @@ +validateRequiredFields(); + $since = $this->data['since'] ?? null; + $until = $this->data['until'] ?? null; + if ($since && $until) { + $sinceCarbon = $since instanceof Carbon ? $since : Carbon::parse($since); + $untilCarbon = $until instanceof Carbon ? $until : Carbon::parse($until); + if ($untilCarbon->lessThanOrEqualTo($sinceCarbon)) { + $this->errors['until'][] = "'until' must be after 'since'."; + $valid = false; + } + } + + return $valid && count($this->errors) === 0; + } +} diff --git a/src/API/Responses/System/SystemLogsResponse.php b/src/API/Responses/System/SystemLogsResponse.php new file mode 100644 index 0000000..cbf378a --- /dev/null +++ b/src/API/Responses/System/SystemLogsResponse.php @@ -0,0 +1,35 @@ + $logs */ + public function __construct( + string $code, + public Collection $logs, + public int $page, + public int $total, + ) { + parent::__construct($code, ''); + } + + /** + * @param array $response + */ + public static function fromArray(array $response): self + { + $hits = collect($response['data']['hits'] ?? [])->map(fn ($item) => SystemLogEntity::fromArray($item)); + + return new self( + $response['code'] ?? '', + $hits, + $response['page'] ?? 1, + $response['total'] ?? 0 + ); + } +} diff --git a/src/API/SystemClient.php b/src/API/SystemClient.php new file mode 100644 index 0000000..2c7ceff --- /dev/null +++ b/src/API/SystemClient.php @@ -0,0 +1,30 @@ +client->post($this::ENDPOINT.'/logs?'.http_build_query($request->getQueryParams()), + $request->getPostBody() + ); + + $data = json_decode($response->getBody()->getContents(), true); + + return SystemLogsResponse::fromArray($data); + } +} diff --git a/src/Entities/System/SystemLogActorEntity.php b/src/Entities/System/SystemLogActorEntity.php new file mode 100644 index 0000000..2ae023b --- /dev/null +++ b/src/Entities/System/SystemLogActorEntity.php @@ -0,0 +1,26 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + $data['alternate_id'] ?? '', + $data['alternate_name'] ?? '', + $data['display_name'] ?? '', + $data['id'] ?? '', + $data['type'] ?? '' + ); + } +} diff --git a/src/Entities/System/SystemLogAuthenticationEntity.php b/src/Entities/System/SystemLogAuthenticationEntity.php new file mode 100644 index 0000000..2dbdcdc --- /dev/null +++ b/src/Entities/System/SystemLogAuthenticationEntity.php @@ -0,0 +1,20 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + $data['credential_provider'] ?? '', + $data['issuer'] ?? '' + ); + } +} diff --git a/src/Entities/System/SystemLogEntity.php b/src/Entities/System/SystemLogEntity.php new file mode 100644 index 0000000..94680e9 --- /dev/null +++ b/src/Entities/System/SystemLogEntity.php @@ -0,0 +1,35 @@ + $data */ + public static function fromArray(array $data): self + { + $source = $data['_source'] ?? []; + + return new self( + Carbon::parse($data['@timestamp'] ?? ''), + $data['_id'] ?? '', + SystemLogActorEntity::fromArray($source['actor'] ?? []), + SystemLogAuthenticationEntity::fromArray($source['authentication'] ?? []), + SystemLogEventEntity::fromArray($source['event'] ?? []), + array_map(fn ($t) => SystemLogTargetEntity::fromArray($t), $source['target'] ?? []), + $data['tag'] ?? '' + ); + } +} diff --git a/src/Entities/System/SystemLogEventEntity.php b/src/Entities/System/SystemLogEventEntity.php new file mode 100644 index 0000000..15302e0 --- /dev/null +++ b/src/Entities/System/SystemLogEventEntity.php @@ -0,0 +1,28 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + $data['display_message'] ?? '', + isset($data['published']) ? Carbon::createFromTimestampMs($data['published']) : Carbon::now(), + $data['reason'] ?? '', + $data['result'] ?? '', + $data['type'] ?? '' + ); + } +} diff --git a/src/Entities/System/SystemLogTargetEntity.php b/src/Entities/System/SystemLogTargetEntity.php new file mode 100644 index 0000000..ea64d87 --- /dev/null +++ b/src/Entities/System/SystemLogTargetEntity.php @@ -0,0 +1,26 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + $data['alternate_id'] ?? '', + $data['alternate_name'] ?? '', + $data['display_name'] ?? '', + $data['id'] ?? '', + $data['type'] ?? '' + ); + } +} diff --git a/tests/API/Requests/Validators/SystemLogValidatorTest.php b/tests/API/Requests/Validators/SystemLogValidatorTest.php new file mode 100644 index 0000000..bb6dacd --- /dev/null +++ b/tests/API/Requests/Validators/SystemLogValidatorTest.php @@ -0,0 +1,66 @@ + 'DOOR_EVENT', + 'since' => Carbon::now()->unix(), + ]; + $validator = new SystemLogValidator($data); + $this->assertTrue($validator->passes()); + $this->assertEmpty($validator->getErrors()->toArray()); + } + + public function test_fails_when_topic_missing(): void + { + $data = [ + 'since' => Carbon::now()->unix(), + ]; + $validator = new SystemLogValidator($data); + $this->assertFalse($validator->passes()); + $this->assertArrayHasKey('topic', $validator->getErrors()->toArray()); + } + + public function test_fails_when_since_missing(): void + { + $data = [ + 'topic' => 'DOOR_EVENT', + ]; + $validator = new SystemLogValidator($data); + $this->assertFalse($validator->passes()); + $this->assertArrayHasKey('since', $validator->getErrors()->toArray()); + } + + public function test_fails_when_both_required_fields_missing(): void + { + $data = []; + $validator = new SystemLogValidator($data); + $this->assertFalse($validator->passes()); + $errors = $validator->getErrors()->toArray(); + $this->assertArrayHasKey('topic', $errors); + $this->assertArrayHasKey('since', $errors); + } + + public function test_fails_when_required_fields_are_empty(): void + { + $data = [ + 'topic' => '', + 'since' => null, + ]; + $validator = new SystemLogValidator($data); + $this->assertFalse($validator->passes()); + $errors = $validator->getErrors()->toArray(); + $this->assertArrayHasKey('topic', $errors); + $this->assertArrayHasKey('since', $errors); + } +} diff --git a/tests/API/SystemClientTest.php b/tests/API/SystemClientTest.php new file mode 100644 index 0000000..3b854a0 --- /dev/null +++ b/tests/API/SystemClientTest.php @@ -0,0 +1,58 @@ + $handlerStack]); + $unifiClient = new UnifiClient($client); + + $request = new SystemLogRequest( + topic: SystemLogTopic::DoorOpenings, + since: Carbon::createFromTimestamp(1690770546), + until: Carbon::createFromTimestamp(1690771546), + actor_id: null, + page_size: 1, + page_num: 25 + ); + + $response = $unifiClient->system()->logs($request); + + $this->assertInstanceOf(SystemLogsResponse::class, $response); + $this->assertEquals('SUCCESS', $response->code); + $this->assertEquals(1, $response->page); + $this->assertEquals(4, $response->total); + $this->assertNotEmpty($response->logs); + $firstLog = $response->logs->first(); + $this->assertInstanceOf(SystemLogEntity::class, $firstLog); + $this->assertEquals('N/A', $firstLog->actor->display_name); + $this->assertEquals('NFC', $firstLog->authentication->credential_provider); + $this->assertEquals('Access Denied / Unknown (NFC)', $firstLog->event->display_message); + $this->assertEquals('BLOCKED', $firstLog->event->result); + $this->assertEquals('access.door.unlock', $firstLog->event->type); + $this->assertEquals('UA-HUB-3855', $firstLog->target[0]->display_name); + $this->assertEquals('access', $firstLog->tag); + $this->assertInstanceOf(Carbon::class, $firstLog->timestamp); + $this->assertInstanceOf(Carbon::class, $firstLog->event->published); + } +} diff --git a/tests/fixtures/system/logs.json b/tests/fixtures/system/logs.json new file mode 100644 index 0000000..fc4b3cc --- /dev/null +++ b/tests/fixtures/system/logs.json @@ -0,0 +1,43 @@ +{ + "code": "SUCCESS", + "data": { + "hits": [ + { + "@timestamp": "2023-07-11T12:11:27Z", + "_id": "", + "_source": { + "actor": { + "alternate_id": "", + "alternate_name": "", + "display_name": "N/A", + "id": "", + "type": "user" + }, + "authentication": { + "credential_provider": "NFC", + "issuer": "6FC02554" + }, + "event": { + "display_message": "Access Denied / Unknown (NFC)", + "published": 1689077487000, + "reason": "", + "result": "BLOCKED", + "type": "access.door.unlock" + }, + "target": [ + { + "alternate_id": "", + "alternate_name": "", + "display_name": "UA-HUB-3855", + "id": "7483c2773855", + "type": "UAH" + } + ] + }, + "tag": "access" + } + ] + }, + "page": 1, + "total": 4 +} \ No newline at end of file From 80da7ec633978380b09afa215ea1dbe74e03663f Mon Sep 17 00:00:00 2001 From: Maarten Kuiper Date: Thu, 23 Oct 2025 20:52:24 +0200 Subject: [PATCH 3/4] Add a missing test for assign QR code on visitor --- tests/API/VisitorClientTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/API/VisitorClientTest.php b/tests/API/VisitorClientTest.php index a36d3f3..e292b69 100644 --- a/tests/API/VisitorClientTest.php +++ b/tests/API/VisitorClientTest.php @@ -222,6 +222,21 @@ public function a_failed_edit_throws_an_exception(): void )); } + #[Test] + public function assign_qr_code_returns_unifi_response(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['code' => 'SUCCESS', 'msg' => 'success'])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $client = new Client(['handler' => $handlerStack]); + $unifiClient = new UnifiClient($client); + $visitorId = new UuidV4('8564ce90-76ba-445f-b78b-6cca39af0130'); + $response = $unifiClient->visitor()->assignQrCode($visitorId); + $this->assertEquals('SUCCESS', $response->code); + $this->assertEquals('success', $response->msg); + } + #[Test] public function a_visitor_can_be_deleted(): void { From 9daf1356a60fc25c85476332b5911d89708a1705 Mon Sep 17 00:00:00 2001 From: Maarten Kuiper Date: Thu, 23 Oct 2025 20:56:34 +0200 Subject: [PATCH 4/4] Add missing tests to SystemLogValidator and SystemLogRequest --- .../Requests/System/SystemLogRequestTest.php | 67 +++++++++++++++++++ .../Validators/SystemLogValidatorTest.php | 19 ++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/API/Requests/System/SystemLogRequestTest.php diff --git a/tests/API/Requests/System/SystemLogRequestTest.php b/tests/API/Requests/System/SystemLogRequestTest.php new file mode 100644 index 0000000..8fff0da --- /dev/null +++ b/tests/API/Requests/System/SystemLogRequestTest.php @@ -0,0 +1,67 @@ + SystemLogTopic::DoorOpenings->value, + 'since' => Carbon::now()->unix(), + 'until' => Carbon::now()->addHour()->unix(), + 'actor_id' => '8564ce90-76ba-445f-b78b-6cca39af0130', + 'page_size' => 10, + 'page_num' => 1, + ]; + $request = SystemLogRequest::fromArray($data); + $this->assertInstanceOf(SystemLogRequest::class, $request); + $this->assertEquals($data['topic'], $request->topic->value); + $this->assertEquals($data['since'], $request->since->unix()); + $this->assertEquals($data['until'], $request->until->unix()); + $this->assertEquals($data['actor_id'], $request->actor_id->getValue()); + $this->assertEquals($data['page_size'], $request->page_size); + $this->assertEquals($data['page_num'], $request->page_num); + } + + public function test_from_array_validation_fails(): void + { + $data = [ + // 'topic' is missing + 'since' => Carbon::now()->unix(), + ]; + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('SystemLogRequest validation failed.'); + SystemLogRequest::fromArray($data); + } + + public function test_from_array_until_not_after_since(): void + { + $now = Carbon::now()->unix(); + $data = [ + 'topic' => SystemLogTopic::DoorOpenings->value, + 'since' => $now, + 'until' => $now, // not after since + ]; + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('SystemLogRequest validation failed.'); + try { + SystemLogRequest::fromArray($data); + } catch (ValidationException $e) { + $errors = $e->errors->toArray(); + $this->assertArrayHasKey('until', $errors); + $this->assertStringContainsString("'until' must be after 'since'.", $errors['until'][0]); + throw $e; + } + } +} + diff --git a/tests/API/Requests/Validators/SystemLogValidatorTest.php b/tests/API/Requests/Validators/SystemLogValidatorTest.php index bb6dacd..06dc8d0 100644 --- a/tests/API/Requests/Validators/SystemLogValidatorTest.php +++ b/tests/API/Requests/Validators/SystemLogValidatorTest.php @@ -63,4 +63,23 @@ public function test_fails_when_required_fields_are_empty(): void $this->assertArrayHasKey('topic', $errors); $this->assertArrayHasKey('since', $errors); } + + public function test_fails_when_until_not_after_since(): void + { + $since = Carbon::now()->unix(); + $until = $since; // 'until' is equal to 'since' + $data = [ + 'topic' => 'DOOR_EVENT', + 'since' => $since, + 'until' => $until, + ]; + $validator = new SystemLogValidator($data); + $this->assertFalse($validator->passes()); + $errors = $validator->getErrors()->toArray(); + $this->assertArrayHasKey('until', $errors); + $this->assertStringContainsString( + "'until' must be after 'since'.", + $errors['until'][0] + ); + } }