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/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/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/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/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/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 @@ $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/src/Entities/ResourceEntity.php b/src/Entities/Visitor/ResourceEntity.php similarity index 94% rename from src/Entities/ResourceEntity.php rename to src/Entities/Visitor/ResourceEntity.php index c8f440d..e61f711 100644 --- a/src/Entities/ResourceEntity.php +++ b/src/Entities/Visitor/ResourceEntity.php @@ -1,6 +1,6 @@ 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 new file mode 100644 index 0000000..06dc8d0 --- /dev/null +++ b/tests/API/Requests/Validators/SystemLogValidatorTest.php @@ -0,0 +1,85 @@ + '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); + } + + 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] + ); + } +} 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/API/VisitorClientTest.php b/tests/API/VisitorClientTest.php index 5e09701..e292b69 100644 --- a/tests/API/VisitorClientTest.php +++ b/tests/API/VisitorClientTest.php @@ -14,7 +14,7 @@ use Uxicodev\UnifiAccessApi\API\Requests\Visitor\VisitorRequest; use Uxicodev\UnifiAccessApi\API\ValueObjects\UuidV4; use Uxicodev\UnifiAccessApi\Client\Client as UnifiClient; -use Uxicodev\UnifiAccessApi\Entities\VisitorEntity; +use Uxicodev\UnifiAccessApi\Entities\Visitor\VisitorEntity; use Uxicodev\UnifiAccessApi\Exceptions\InvalidResponseException; use Uxicodev\UnifiAccessApi\Exceptions\UnifiApiErrorException; use Uxicodev\UnifiAccessApi\UnifiAccessApiServiceProvider; @@ -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 { diff --git a/tests/Entities/ResourceEntityTest.php b/tests/Entities/ResourceEntityTest.php index 6b581fd..08faa5c 100644 --- a/tests/Entities/ResourceEntityTest.php +++ b/tests/Entities/ResourceEntityTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use Uxicodev\UnifiAccessApi\API\Enums\ResourceType; use Uxicodev\UnifiAccessApi\API\ValueObjects\UuidV4; -use Uxicodev\UnifiAccessApi\Entities\ResourceEntity; +use Uxicodev\UnifiAccessApi\Entities\Visitor\ResourceEntity; class ResourceEntityTest extends TestCase { 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