From e4920b1acedf13c26b793a3a01678057cfcda77a Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 30 Apr 2026 00:18:43 -0400 Subject: [PATCH 1/3] Update Attestations to be in line with Transunion's API nullable values --- src/Model/Attestation.php | 23 ++-- src/Model/AttestationGroup.php | 14 +-- test/Unit/Model/AttestationGroupTest.php | 129 +++++++++++++++++++++++ test/Unit/Model/AttestationTest.php | 38 +++---- 4 files changed, 160 insertions(+), 44 deletions(-) create mode 100644 test/Unit/Model/AttestationGroupTest.php diff --git a/src/Model/Attestation.php b/src/Model/Attestation.php index cb99516..e255329 100644 --- a/src/Model/Attestation.php +++ b/src/Model/Attestation.php @@ -4,8 +4,6 @@ namespace Rentpost\TUShareable\Model; -use Symfony\Component\Validator\Constraints as Assert; - /** * Class that represents a single attestation. */ @@ -18,39 +16,34 @@ class Attestation public function __construct( private int $attestationId, private int $attestationTypeId, - - #[Assert\NotBlank] - private string $name, - - #[Assert\NotBlank] - private string $legalText, - + private ?string $name, + private ?string $legalText, private bool $affirmativeRequired, - private string $additionalInformation, + private ?string $additionalInformation, ) { $this->validate(); } - public function getAttestationId(): ?int + public function getAttestationId(): int { return $this->attestationId; } - public function getAttestationTypeId(): ?int + public function getAttestationTypeId(): int { return $this->attestationTypeId; } - public function getName(): string + public function getName(): ?string { return $this->name; } - public function getLegalText(): string + public function getLegalText(): ?string { return $this->legalText; } @@ -62,7 +55,7 @@ public function isAffirmativeRequired(): bool } - public function getAdditionalInformation(): string + public function getAdditionalInformation(): ?string { return $this->additionalInformation; } diff --git a/src/Model/AttestationGroup.php b/src/Model/AttestationGroup.php index dc6621a..c5c9a19 100644 --- a/src/Model/AttestationGroup.php +++ b/src/Model/AttestationGroup.php @@ -55,20 +55,20 @@ public function addAttestationResponse(int $attestationId, bool $isAffirmative): } - /** @param array> $data */ + /** @param array|null> $data */ public static function fromArray(array $data): self { $attestationGroupId = $data['attestationGroupId']; $attestations = []; - foreach ($data['attestations'] as $attestation) { + foreach (($data['attestations'] ?? []) as $attestation) { $attestations[] = new Attestation( $attestation['attestationId'], $attestation['attestationTypeId'], - $attestation['name'], - $attestation['legalText'], + $attestation['name'] ?? null, + $attestation['legalText'] ?? null, $attestation['affirmativeRequired'], - $attestation['additionalInformation'], + $attestation['additionalInformation'] ?? null, ); } @@ -83,11 +83,11 @@ public function toArray(): array 'attestationGroupId' => $this->attestationGroupId, ]; - foreach ($this->attestations as $attestation) { + foreach (($this->attestations ?? []) as $attestation) { $array['attestations'][] = $attestation->toArray(); } - foreach ($this->attestationResponses as $attestationResponse) { + foreach (($this->attestationResponses ?? []) as $attestationResponse) { $array['attestationResponses'][] = $attestationResponse->toArray(); } diff --git a/test/Unit/Model/AttestationGroupTest.php b/test/Unit/Model/AttestationGroupTest.php new file mode 100644 index 0000000..dad6fce --- /dev/null +++ b/test/Unit/Model/AttestationGroupTest.php @@ -0,0 +1,129 @@ +assertSame(123, $group->getAttestationGroupId()); + $this->assertCount(1, $group->getAttestations()); + $this->assertNull($group->getAttestationsResponses()); + } + + + public function testConstructorWithoutAttestations(): void + { + $group = new AttestationGroup(123); + + $this->assertSame(123, $group->getAttestationGroupId()); + $this->assertNull($group->getAttestations()); + } + + + public function testFromArrayWithAttestations(): void + { + $group = AttestationGroup::fromArray([ + 'attestationGroupId' => 123, + 'attestations' => [ + [ + 'attestationId' => 1, + 'attestationTypeId' => 2, + 'name' => 'CA ICRAA', + 'legalText' => 'I certify...', + 'affirmativeRequired' => true, + 'additionalInformation' => null, + ], + ], + ]); + + $this->assertSame(123, $group->getAttestationGroupId()); + $this->assertCount(1, $group->getAttestations()); + } + + + public function testFromArrayWithMissingAttestationsKey(): void + { + $group = AttestationGroup::fromArray(['attestationGroupId' => 123]); + + $this->assertSame(123, $group->getAttestationGroupId()); + $this->assertSame([], $group->getAttestations()); + } + + + public function testFromArrayWithNullAttestations(): void + { + $group = AttestationGroup::fromArray([ + 'attestationGroupId' => 123, + 'attestations' => null, + ]); + + $this->assertSame(123, $group->getAttestationGroupId()); + $this->assertSame([], $group->getAttestations()); + } + + + public function testFromArrayWithNullableAttestationFields(): void + { + $group = AttestationGroup::fromArray([ + 'attestationGroupId' => 123, + 'attestations' => [ + [ + 'attestationId' => 1, + 'attestationTypeId' => 1, + 'affirmativeRequired' => false, + // name, legalText, additionalInformation all absent + ], + ], + ]); + + $attestation = $group->getAttestations()[0]; + $this->assertNull($attestation->getName()); + $this->assertNull($attestation->getLegalText()); + $this->assertNull($attestation->getAdditionalInformation()); + } + + + public function testToArrayWithoutAttestationsOrResponses(): void + { + $group = new AttestationGroup(123); + + $this->assertSame(['attestationGroupId' => 123], $group->toArray()); + } + + + public function testToArrayWithAttestationsAndResponses(): void + { + $attestation = new Attestation(1, 2, 'Name', 'Legal', true, null); + $group = new AttestationGroup(123, [$attestation]); + $group->addAttestationResponse(1, true); + + $array = $group->toArray(); + + $this->assertSame(123, $array['attestationGroupId']); + $this->assertCount(1, $array['attestations']); + $this->assertCount(1, $array['attestationResponses']); + $this->assertSame(['attestationId' => 1, 'isAffirmative' => true], $array['attestationResponses'][0]); + } + + + public function testAddAttestationResponseWithFalseAffirmation(): void + { + $group = new AttestationGroup(123); + $group->addAttestationResponse(7, false); + + $responses = $group->getAttestationsResponses(); + $this->assertCount(1, $responses); + $this->assertFalse($responses[0]->isAffirmative()); + } +} diff --git a/test/Unit/Model/AttestationTest.php b/test/Unit/Model/AttestationTest.php index 33de07d..363c00a 100644 --- a/test/Unit/Model/AttestationTest.php +++ b/test/Unit/Model/AttestationTest.php @@ -4,10 +4,8 @@ namespace Test\Unit\Rentpost\TUShareable\Model; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Rentpost\TUShareable\Model\Attestation; -use Rentpost\TUShareable\ValidationException; class AttestationTest extends TestCase { @@ -42,29 +40,25 @@ public function testConstructorAndGetters(): void /** - * @return array> + * Per the TU API spec, name, legalText, and additionalInformation are nullable. + * Construction must not throw when any (or all) are null. */ - public static function validationProvider(): array + public function testNullableStringFields(): void { - return [ - // name missing - [ [1, 2, '', 'Legal Text', true, 'Additional Information'], 'name', 'This value should not be blank.' ], - // legal text missing - [ [1, 2, 'Name', '', true, 'Additional Information'], 'legalText', 'This value should not be blank.' ], - ]; - } - + $attestation = new Attestation(1, 2, null, null, false, null); - /** - * @param string[] $values - */ - #[DataProvider('validationProvider')] - public function testValidationErrors(array $values, string $field, string $errorMessage): void - { - $this->expectException(ValidationException::class); - $this->expectExceptionMessage("Object(Rentpost\TUShareable\Model\Attestation).$field"); - $this->expectExceptionMessage($errorMessage); + $this->assertNull($attestation->getName()); + $this->assertNull($attestation->getLegalText()); + $this->assertNull($attestation->getAdditionalInformation()); + $this->assertFalse($attestation->isAffirmativeRequired()); - new Attestation(...$values); + $this->assertSame([ + 'attestationId' => 1, + 'attestationTypeId' => 2, + 'name' => null, + 'legalText' => null, + 'affirmativeRequired' => false, + 'additionalInformation' => null, + ], $attestation->toArray()); } } From b10f5bf89e1a4e3a53615ae3cb69e53a5915b739 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 30 Apr 2026 00:44:24 -0400 Subject: [PATCH 2/3] Add GitHub CI for unit tests - Add .github/workflows/ci.yml running phpunit --testsuite unit on PHP 8.4 for every push and PR against master - Switch coding-standard-php repository URL from SSH to HTTPS now that the package is public, so composer install works in CI without auth --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ composer.json | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0158017 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +# Security review: this workflow uses no untrusted inputs (no github.event.issue.*, +# pull_request.*body/title, head_commit.message, etc.). All ${{ ... }} expansions +# are static (github.workflow, github.ref, matrix.php) and safe. +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: Unit tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4'] + steps: + - uses: actions/checkout@v4 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + composer-options: '--no-interaction --no-progress --prefer-dist' + + - name: Run unit tests + run: vendor/bin/phpunit --testsuite unit diff --git a/composer.json b/composer.json index 9eefd4c..1fdf0c2 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "repositories": [ { "type": "vcs", - "url": "git@github.com:rentpost/coding-standard-php.git" + "url": "https://github.com/rentpost/coding-standard-php.git" } ], "autoload": { From 175719283b34c27830466f445e8a7d7eb404a9fd Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 30 Apr 2026 00:54:57 -0400 Subject: [PATCH 3/3] Add manual integration-tests job to CI Triggered only by workflow_dispatch so PR runs never burn TransUnion sandbox quota and secrets aren't exposed to fork PRs. Required repository secrets: TU_SHAREABLE_URL, TU_SHAREABLE_CLIENT_ID, TU_SHAREABLE_API_KEY_ONE, TU_SHAREABLE_API_KEY_TWO. --- .github/workflows/ci.yml | 47 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0158017..c416ff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ # Security review: this workflow uses no untrusted inputs (no github.event.issue.*, # pull_request.*body/title, head_commit.message, etc.). All ${{ ... }} expansions -# are static (github.workflow, github.ref, matrix.php) and safe. +# are static (github.workflow, github.ref, matrix.php) or pulled from secrets via +# env, never interpolated into shell commands. name: CI on: @@ -8,6 +9,7 @@ on: branches: [master] pull_request: branches: [master] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -38,3 +40,46 @@ jobs: - name: Run unit tests run: vendor/bin/phpunit --testsuite unit + + # Integration tests hit the real TransUnion ShareAble sandbox API and require + # credentials. They run only on manual maintainer trigger so PR validation + # never burns sandbox quota and so secrets are never exposed to fork PRs. + # + # Required repository secrets (Settings → Secrets and variables → Actions): + # TU_SHAREABLE_URL, TU_SHAREABLE_CLIENT_ID, + # TU_SHAREABLE_API_KEY_ONE, TU_SHAREABLE_API_KEY_TWO + integration-tests: + name: Integration tests (TU sandbox) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + composer-options: '--no-interaction --no-progress --prefer-dist' + + - name: Write sandbox config + env: + TU_URL: ${{ secrets.TU_SHAREABLE_URL }} + TU_CLIENT_ID: ${{ secrets.TU_SHAREABLE_CLIENT_ID }} + TU_API_KEY_ONE: ${{ secrets.TU_SHAREABLE_API_KEY_ONE }} + TU_API_KEY_TWO: ${{ secrets.TU_SHAREABLE_API_KEY_TWO }} + run: | + { + printf 'url="%s"\n' "$TU_URL" + printf 'clientId="%s"\n' "$TU_CLIENT_ID" + printf 'apiKeyOne="%s"\n' "$TU_API_KEY_ONE" + printf 'apiKeyTwo="%s"\n' "$TU_API_KEY_TWO" + } > config + + - name: Run integration tests + run: vendor/bin/phpunit --testsuite integration