11 Commits
Author SHA1 Message Date
Joel Male 32266f6dd5 fix: revert JSON serialization workaround, upgrade SDK to 6.3.1 instead
v2.0.1's useJson() workaround was wrong: the JSON request serializer
silently drops nested single-object fields (e.g. CustomerRef, which
QuickBooks requires), and the JSON response deserializer never returns
a typed entity, so $result->Id is always null downstream. That breaks
every dsCall() site, worse than the bug it was meant to fix.

The actual root cause was that quickbooks/v3-php-sdk 5.4.7 predates
QuickBooks Online's new invoice AllowOnlinePayPalPayment field, so its
XML deserializer throws on responses that include it. SDK 6.3.1
declares the field, so upgrading resolves the original error while
keeping the fully-supported XML path (and closes out the SDK bump
Dependabot already had queued in #3).

Verified: deserializing a response containing AllowOnlinePayPalPayment
throws RuntimeException on 5.4.7 and returns a correctly typed
IPPInvoice (with Id populated) on 6.3.1.
2026-07-27 07:15:59 +03:00
Joel Male 9002e0ebca fix: switch DataService to JSON serialization to survive QuickBooks schema drift
The SDK's XML deserializer reflects on declared properties of its Data
classes and throws a RuntimeException when QuickBooks returns a field
the installed SDK version doesn't define yet (e.g. the new invoice
AllowOnlinePayPalPayment field), breaking every invoice create/update.
The JSON path builds instances from the class's own known properties
and silently ignores unrecognised response fields instead, so it
tolerates QuickBooks adding fields ahead of the SDK.
2026-07-27 07:03:21 +03:00
pixel-workflows[bot] 473103c243 chore: update dependabot configuration 2026-05-27 06:18:22 +00:00
Joel Male f59295773d Create SECURITY.md 2026-05-27 14:48:52 +10:00
Joel Male 446f6788d2 chore: bump version to 2.0.0 2026-02-18 10:47:51 +10:00
Joel Male 831cd49719 chore: clean up 2026-02-18 10:46:22 +10:00
Joel Male bb2d375450 docs: rewrite README for current package state
- Replace broken Scrutinizer/poser.pugx badges with shields.io
- Add PHP version badge
- Add Laravel/PHP compatibility table
- Fix migration command (remove deprecated --package flag)
- Modernize usage example with FQCN resolution
- Simplify and tighten all sections
- Remove outdated Psy Shell version references
- Fix middleware class path reference
- Add testing section
2026-02-18 10:45:27 +10:00
Joel Male 861a08e083 test: add tests for upgrade-sensitive areas
- Token model structure: verify $casts has datetime fields, $fillable
  is complete, and $table is set correctly (catches $dates→$casts regression)
- ClientServiceProvider: verify DeferrableProvider interface is
  implemented and provides() returns Client::class (catches $defer regression)
- Token parseOauthToken: verify stored values and Carbon types, not
  just that mock methods were called
- Client setToken: verify DataService cache is cleared on token change
- Client delegation: verify hasValidAccessToken/hasValidRefreshToken
  delegate to Token correctly
2026-02-18 10:43:14 +10:00
Joel Male 97c04ec693 feat: migrate test suite from PHPUnit to Pest
- Replace phpunit/phpunit with pestphp/pest ^3.0
- Drop scrutinizer/ocular (incompatible with Pest's symfony/console)
- Add tests/Pest.php with MockeryPHPUnitIntegration trait binding
- Extract namespace-level function stubs (config, route, dir) to
  tests/Overrides.php, loaded via autoload-dev files
- Convert all 5 test classes to Pest closure-based syntax:
  setUp() → beforeEach(), assertions → expect(), skip() for skipped tests
- Delete tests/TestCase.php (logic moved to Pest.php)
- Stubs (TokenStub, User) unchanged
2026-02-18 10:38:20 +10:00
Joel Male 8ec2c3cc38 feat: add Laravel 11/12 and PHP 8.3+ support
- Widen illuminate/* deps to ^10.0|^11.0|^12.0, require PHP ^8.3
- Upgrade PHPUnit to ^10.5|^11.0, Mockery to ^1.6
- Rewrite phpunit.xml.dist for PHPUnit 10+ format (source/include
  replaces filter/whitelist, removed deprecated attributes)
- Replace $dates with $casts on Token model
- Implement DeferrableProvider interface instead of $defer property
  on ClientServiceProvider
- Use FQCN route definitions instead of string-based namespace()
- Fix migration getColumnType() check for Laravel 11+ native types
- Replace @test doc annotations with #[Test] attributes
- Fix dynamic property deprecation in ControllerTest
2026-02-18 10:29:34 +10:00
divakar-rajodiya 32f5c22846 update composer.json 2024-06-26 09:41:25 +05:30
23 changed files with 707 additions and 1059 deletions
-9
View File
@@ -1,9 +0,0 @@
* text=auto
/build export-ignore
/tests export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.travis.yml export-ignore
nitpick.json export-ignore
phpunit.xml.dist export-ignore
+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
groups:
actions:
patterns: ["*"]
commit-message:
prefix: "chore"
include: "scope"
labels:
- "dependencies"
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
day: "tuesday"
open-pull-requests-limit: 10
groups:
php-minor-patch:
update-types:
- "minor"
- "patch"
commit-message:
prefix: "chore"
include: "scope"
labels:
- "dependencies"
+3
View File
@@ -5,3 +5,6 @@ phpunit.xml
.phpunit.result.cache
composer.lock
/.idea/
.cursor
.claude
CLAUDE.md
+59 -71
View File
@@ -1,115 +1,103 @@
# Wearepixel's Laravel QuickBooks Client
# Laravel QuickBooks Client
[![Latest Stable Version](https://poser.pugx.org/wearepixel/laravel-quickbooks/v/stable)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
[![Latest Unstable Version](https://poser.pugx.org/wearepixel/laravel-quickbooks/v/unstable)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
[![Total Downloads](https://poser.pugx.org/wearepixel/laravel-quickbooks/downloads)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
[![License](https://poser.pugx.org/wearepixel/laravel-quickbooks/license)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
[![Latest Stable Version](https://img.shields.io/packagist/v/wearepixel/laravel-quickbooks.svg)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
[![Total Downloads](https://img.shields.io/packagist/dt/wearepixel/laravel-quickbooks.svg)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
[![License](https://img.shields.io/packagist/l/wearepixel/laravel-quickbooks.svg)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
[![PHP Version](https://img.shields.io/packagist/dependency-v/wearepixel/laravel-quickbooks/php.svg)](https://packagist.org/packages/wearepixel/laravel-quickbooks)
PHP client wrapping the [QuickBooks PHP SDK](https://github.com/intuit/QuickBooks-V3-PHP-SDK).
A Laravel package wrapping the [QuickBooks PHP SDK](https://github.com/intuit/QuickBooks-V3-PHP-SDK). Provides OAuth 2.0 authentication, automatic token management, and access to the QuickBooks Online API.
We solely use [Laravel](https://www.laravel.com) for our applications, so this package is written with Laravel in mind. If there is a request from the community to split this package into 2 parts to allow it to be used outside of Laravel, then we will consider doing that work.
## Compatibility
## Build Status
| Branch | Status | Coverage | Code Quality |
| ------ | :----: | :------: | :----------: |
| Master | [![Build Status](https://github.com/wearepixel/laravel-quickbooks/workflows/CI/badge.svg?branch=master)](https://github.com/wearepixel/laravel-quickbooks/workflows/CI/badge.svg?branch=master) | [![Code Coverage](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks/?branch=master) | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks/?branch=main) |
| Laravel | PHP | Package |
|---------|------|---------|
| 12.x | ^8.3 | ^2.0 |
| 11.x | ^8.3 | ^2.0 |
| 10.x | ^8.3 | ^1.0 |
## Installation
1. Install QuickBooks PHP Client:
1. Install the package:
```bash
$ composer require wearepixel/laravel-quickbooks
composer require wearepixel/laravel-quickbooks
```
2. Run our migration to install the `quickbooks_tokens` table:
2. Run the migration to create the `quickbooks_tokens` table:
```bash
$ php artisan migrate --package=wearepixel/laravel-quickbooks
php artisan migrate
```
The package uses the [auto registration feature](https://laravel.com/docs/packages#package-discovery) of Laravel.
The package uses Laravel's [auto-discovery](https://laravel.com/docs/packages#package-discovery), so no manual provider registration is needed.
## Configuration
1. You will need a ```quickBooksToken``` relationship on your ```User``` model. There is a trait named ```Wearepixel\QuickBooks\HasQuickBooksToken```, which you can include on your ```User``` model, which will setup the relationship. To do this implement the following:
Add ```use Wearepixel\QuickBooks\HasQuickBooksToken;``` to your service container at the top of User.php
and also add the trait within the class. For example:
### 1. Add the trait to your User model
```php
use Wearepixel\QuickBooks\HasQuickBooksToken;
class User extends Authenticatable
{
use Notifiable, HasQuickBooksToken;
use HasQuickBooksToken;
}
```
**NOTE: If your ```User``` model is not ```App\Models\User```, then you will need to configure the path in the ```configs/quickbooks.php```.**
If your User model is not `App\Models\User`, publish the config and update the `user.model` value.
2. Add the appropriate values to your ```.env```
### 2. Set your environment variables
#### Minimal Keys
```bash
QUICKBOOKS_CLIENT_ID=<client id given by QuickBooks>
QUICKBOOKS_CLIENT_SECRET=<client secret>
```
```bash
QUICKBOOKS_CLIENT_ID=<your client id>
QUICKBOOKS_CLIENT_SECRET=<your client secret>
```
#### Optional Keys
```bash
QUICKBOOKS_API_URL=<Development|Production> # Defaults to App's env value
QUICKBOOKS_DEBUG=<true|false> # Defaults to App's debug value
```
Optional:
3. _[Optional]_ Publish configs & views
```bash
QUICKBOOKS_API_URL=<Development|Production> # Defaults based on APP_ENV
QUICKBOOKS_DEBUG=<true|false> # Defaults to APP_DEBUG
```
#### Config
A configuration file named ```quickbooks.php``` can be published to ```config/``` by running...
### 3. Publish config and views (optional)
```bash
php artisan vendor:publish --tag=quickbooks-config
```
#### Views
View files can be published by running...
```bash
php artisan vendor:publish --tag=quickbooks-views
```
```bash
php artisan vendor:publish --tag=quickbooks-config
php artisan vendor:publish --tag=quickbooks-views
```
## Usage
Here is an example of getting the company information from QuickBooks:
### NOTE: Before doing these commands, go to your connect route (default: /quickbooks/connect) to get a QuickBooks token for your user
First, direct users to `/quickbooks/connect` to authorize their QuickBooks account. Once connected, you can access the API:
```php
php artisan tinker
Psy Shell v0.8.17 (PHP 7.1.14 — cli) by Justin Hileman
>>> Auth::logInUsingId(1)
=> App\Models\User {#1668
id: 1,
// Other keys removed for example
}
>>> $quickbooks = app('Wearepixel\QuickBooks\Client') // or app('QuickBooks')
=> Wearepixel\QuickBooks\Client {#1613}
>>> $quickbooks->getDataService()->getCompanyInfo();
=> QuickBooksOnline\API\Data\IPPCompanyInfo {#1673
+CompanyName: "Sandbox Company_US_1",
+LegalName: "Sandbox Company_US_1",
// Other properties removed for example
}
>>>
$quickbooks = app(\Wearepixel\QuickBooks\Client::class);
// Get company info
$company = $quickbooks->getDataService()->getCompanyInfo();
// Access reports
$reportService = $quickbooks->getReportService();
```
You can call any of the resources as documented [in the SDK](https://intuit.github.io/QuickBooks-V3-PHP-SDK/quickstart.html).
You can call any of the resources documented in the [QuickBooks PHP SDK](https://intuit.github.io/QuickBooks-V3-PHP-SDK/quickstart.html).
## Middleware
If you have routes that will be dependent on the user's account having a usable QuickBooks OAuth token, there is an included middleware ```Wearepixel\QuickBooks\Laravel\Filter``` that gets registered as ```quickbooks``` that will ensure the account is linked and redirect them to the `connect` route if needed.
Here is an example route definition:
Protect routes that require a QuickBooks connection using the `quickbooks` middleware. Users without a valid token will be redirected to the connect page.
```php
Route::view('some/route/needing/quickbooks/token/before/using', 'some.view')
Route::get('quickbooks/invoices', InvoiceController::class)
->middleware('quickbooks');
```
## Testing
```bash
./vendor/bin/pest
```
## License
MIT
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
## Reporting a Vulnerability
If you discover a security vulnerability, please report it privately using the below channel:
**GitHub Private Vulnerability Reporting** — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
All security vulnerabilities will be promptly addressed.
-1
View File
@@ -1 +0,0 @@
4.0.4
+19 -14
View File
@@ -1,7 +1,7 @@
{
"name": "wearepixel/laravel-quickbooks",
"version": "1.0.0",
"description": "Wearepixel's Laravel Client for QuickBooks.",
"version": "2.0.2",
"description": "Laravel Client for QuickBooks.",
"keywords": [
"quickbooks",
"client",
@@ -16,20 +16,19 @@
}
],
"require": {
"php": "^8.0.2",
"illuminate/database": "^10.0|^11.0",
"illuminate/http": "^10.0|^11.0",
"illuminate/routing": "^10.0|^11.0",
"illuminate/support": "^10.0|^11.0",
"nesbot/carbon": "^2.62.1",
"quickbooks/v3-php-sdk": "^5.4.6"
"php": "^8.3",
"illuminate/database": "^10.0|^11.0|^12.0",
"illuminate/http": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.62.1|^3.0",
"quickbooks/v3-php-sdk": "^6.3.1"
},
"require-dev": {
"laravel/pint": "^1.2",
"mockery/mockery": "^1.5.1",
"phpunit/phpunit": "^10.0",
"psy/psysh": "^0.11",
"scrutinizer/ocular": "^1.9"
"mockery/mockery": "^1.6",
"pestphp/pest": "^3.0",
"psy/psysh": "^0.12"
},
"autoload": {
"psr-4": {
@@ -39,7 +38,10 @@
"autoload-dev": {
"psr-4": {
"Wearepixel\\QuickBooks\\": "tests/"
}
},
"files": [
"tests/Overrides.php"
]
},
"extra": {
"laravel": {
@@ -50,6 +52,9 @@
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
},
"sort-packages": true
},
"prefer-stable": true
@@ -15,8 +15,7 @@
public function up()
{
Schema::create('quickbooks_tokens', function (Blueprint $table) {
$user_id_type =
DB::getSchemaBuilder()->getColumnType('users', 'id') === 'bigint'
$user_id_type = str_contains(DB::getSchemaBuilder()->getColumnType('users', 'id'), 'bigint')
? 'unsignedBigInteger'
: 'unsignedInteger';
-9
View File
@@ -1,9 +0,0 @@
* text=auto
/build export-ignore
/tests export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.travis.yml export-ignore
nitpick.json export-ignore
phpunit.xml.dist export-ignore
+6 -23
View File
@@ -8,15 +8,11 @@
-->
<phpunit backupGlobals="false"
backupStaticAttributes="false"
backupStaticProperties="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
verbose="true">
stopOnFailure="false">
<testsuites>
<testsuite name="Wearepixel's Laravel QuickBooks Client">
@@ -28,26 +24,13 @@
<!-- <env name="VARIABLE" value="value"/> -->
</php>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
<exclude>
<directory>./src/Http/Exceptions</directory>
<directory>./src/Providers</directory>
</exclude>
</whitelist>
</filter>
<logging>
<log type="coverage-html"
target="./build/phpunit/coverage"
lowUpperBound="35"
highLowerBound="70"/>
<log type="coverage-text"
target="php://stdout"
showOnlySummary="true"
showUncoveredFiles="false"/>
<log type="coverage-clover" target="build/phpunit/logs/clover.xml"/>
<log type="junit" target="./build/phpunit/logs/junit.xml"/>
</logging>
</source>
</phpunit>
+1 -2
View File
@@ -22,8 +22,7 @@ public function __construct(
protected Redirector $redirector,
protected Session $session,
protected UrlGenerator $url_generator,
) {
}
) {}
/**
* Handle an incoming request.
+2 -6
View File
@@ -3,19 +3,15 @@
namespace Wearepixel\QuickBooks\Providers;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider as LaravelServiceProvider;
use Wearepixel\QuickBooks\Client;
/**
* Class ClientServiceProvider
*/
class ClientServiceProvider extends LaravelServiceProvider
class ClientServiceProvider extends LaravelServiceProvider implements DeferrableProvider
{
/**
* Indicates if loading of the provider is deferred.
*/
protected bool $defer = true;
/**
* Get the services provided by the provider.
*/
+9 -8
View File
@@ -2,8 +2,8 @@
namespace Wearepixel\QuickBooks\Providers;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider as LaravelServiceProvider;
use Wearepixel\QuickBooks\Http\Controllers\Controller;
use Wearepixel\QuickBooks\Http\Middleware\Filter;
/**
@@ -89,19 +89,20 @@ protected function registerRoutes(): void
->prefix($config['prefix'])
->as('quickbooks.')
->middleware($config['middleware']['default'])
->namespace('Wearepixel\QuickBooks\Http\Controllers')
->group(function (Router $router) use ($config) {
$router
->get($config['paths']['connect'], 'Controller@connect')
->group(function () use ($config) {
$this->app->router
->get($config['paths']['connect'], [Controller::class, 'connect'])
->middleware($config['middleware']['authenticated'])
->name('connect');
$router
->delete($config['paths']['disconnect'], 'Controller@disconnect')
$this->app->router
->delete($config['paths']['disconnect'], [Controller::class, 'disconnect'])
->middleware($config['middleware']['authenticated'])
->name('disconnect');
$router->get($config['paths']['token'], 'Controller@token')->name('token');
$this->app->router
->get($config['paths']['token'], [Controller::class, 'token'])
->name('token');
});
}
+6 -3
View File
@@ -34,11 +34,14 @@ class Token extends Model
protected $table = 'quickbooks_tokens';
/**
* The attributes that should be mutated to dates.
* The attributes that should be cast.
*
* @var array
* @var array<string, string>
*/
protected $dates = ['access_token_expires_at', 'refresh_token_expires_at'];
protected $casts = [
'access_token_expires_at' => 'datetime',
'refresh_token_expires_at' => 'datetime',
];
/**
* The attributes that are mass assignable.
+80 -181
View File
@@ -1,34 +1,11 @@
<?php
namespace Wearepixel\QuickBooks;
use Mockery;
use Mockery\Mock;
use QuickBooksOnline\API\DataService\DataService;
use QuickBooksOnline\API\Exception\ServiceException;
use Wearepixel\QuickBooks\Client;
use Wearepixel\QuickBooks\Token;
/**
* Class ClientTest
*/
class ClientTest extends TestCase
{
/**
* @var Client
*/
protected $client;
/**
* @var array
*/
protected $configs = [];
/**
* @var Mock
*/
protected $token_mock;
protected function setUp(): void
{
beforeEach(function () {
$this->configs = [
'data_service' => [
'auth_mode' => 'oauth2',
@@ -40,32 +17,14 @@ protected function setUp(): void
];
$this->token_mock = Mockery::mock(Token::class);
$this->client = new Client($this->configs, $this->token_mock);
});
$this->client = $this->makeClient();
}
it('can be constructed', function () {
expect($this->client)->toBeInstanceOf(Client::class);
});
private function makeClient($configs = null)
{
if (! is_null($configs)) {
return new Client($configs, $this->token_mock);
}
return new Client($this->configs, $this->token_mock);
}
/**
* @test
*/
public function it_can_be_constructed()
{
$this->assertInstanceOf(Client::class, $this->client);
}
/**
* @test
*/
public function it_returns_a_data_service_configured_to_request_oauth_token_when_token_is_empty()
{
it('returns a data service configured to request oauth token when token is empty', function () {
$this->token_mock
->shouldReceive('getAttribute')
->twice()
@@ -78,19 +37,15 @@ public function it_returns_a_data_service_configured_to_request_oauth_token_when
->with('hasValidRefreshToken')
->andReturnFalse();
$this->assertFalse(
expect(
$this->client
->getDataService()
->getServiceContext()
->IppConfiguration->Security->isAccessTokenSet(),
);
}
)->toBeFalse();
});
/**
* @test
*/
public function it_caches_the_data_service_once_it_is_made()
{
it('caches the data service once it is made', function () {
$this->token_mock
->shouldReceive('getAttribute')
->twice()
@@ -103,15 +58,10 @@ public function it_caches_the_data_service_once_it_is_made()
->with('hasValidRefreshToken')
->andReturnFalse();
// TODO: Find out if this is the actual goal of this test, to make sure a DataService is received
$this->assertEquals(DataService::class, get_class($this->client->getDataService()));
}
expect($this->client->getDataService())->toBeInstanceOf(DataService::class);
});
/**
* @test
*/
public function it_returns_a_data_service_with_oauth_token_when_valid_access_token_exist()
{
it('returns a data service with oauth token when valid access token exists', function () {
$this->token_mock
->shouldReceive('getAttribute')
->twice()
@@ -136,21 +86,15 @@ public function it_returns_a_data_service_with_oauth_token_when_valid_access_tok
->with('refresh_token')
->andReturn('refresh_token');
$this->assertTrue(
expect(
$this->client
->getDataService()
->getServiceContext()
->IppConfiguration->Security->isAccessTokenSet(),
);
}
/**
* @test
*/
public function it_returns_a_data_service_with_refreshed_token_when_access_token_expired_but_refresh_token_valid()
{
$this->expectException(ServiceException::class);
)->toBeTrue();
});
it('returns a data service with refreshed token when access token expired but refresh token valid', function () {
$this->token_mock
->shouldReceive('getAttribute')
->twice()
@@ -180,120 +124,75 @@ public function it_returns_a_data_service_with_refreshed_token_when_access_token
->with('refresh_token')
->andReturn('refresh_token');
// TODO: This really needs to test that things are getting called correctly on the DataService class
$this->assertFalse(
$this->client
->getDataService()
->getServiceContext()
->IppConfiguration->Security->isAccessTokenSet(),
);
}
$this->client->getDataService();
})->throws(ServiceException::class);
/**
* @test
*/
public function it_returns_a_report_service_using_the_data_service()
{
$this->markTestSkipped(
'Once we figure out how to test around the static DataService::Configure',
);
}
it('returns a report service using the data service')
->skip('Once we figure out how to test around the static DataService::Configure');
/**
* @test
*/
public function it_has_logging_off_by_default()
{
$this->markTestSkipped('Have to figure out how to test this with the new code in 5.x');
it('has logging off by default')
->skip('Have to figure out how to test this with the new code in 5.x');
$this->token_mock
->shouldReceive('getAttribute')
->once()
->with('hasValidAccessToken')
->andReturnFalse();
it('allows logging turned on and pointed to expected file')
->skip('Have to figure out how to test this with the new code in 5.x');
$this->token_mock
->shouldReceive('getAttribute')
->once()
->with('hasValidRefreshToken')
->andReturnFalse();
$this->assertFalse(
filter_var(
$this->client->getDataService()->getServiceContext()->IppConfiguration->Logger
->RequestLog->EnableRequestResponseLogging,
FILTER_VALIDATE_BOOLEAN,
),
);
}
/**
* @test
*/
public function it_allows_logging_turned_on_and_pointed_to_expected_file()
{
$this->markTestSkipped('Have to figure out how to test this with the new code in 5.x');
$this->client = $this->makeClient(
array_merge(
[
'logging' => [
'enabled' => true,
'location' => '/some/valid/path',
],
],
$this->configs,
),
);
$this->token_mock
->shouldReceive('getAttribute')
->once()
->with('hasValidAccessToken')
->andReturnFalse();
$this->token_mock
->shouldReceive('getAttribute')
->once()
->with('hasValidRefreshToken')
->andReturnFalse();
$this->assertTrue(
filter_var(
$this->client->getDataService()->getServiceContext()->IppConfiguration->Logger
->RequestLog->EnableRequestResponseLogging,
FILTER_VALIDATE_BOOLEAN,
),
);
$this->assertEquals(
'/some/valid/path',
$this->client->getDataService()->getServiceContext()->IppConfiguration->Logger
->RequestLog->ServiceRequestLoggingLocation,
);
}
/**
* @test
*/
public function it_returns_self_after_deleting_token()
{
it('returns self after deleting token', function () {
$this->token_mock
->shouldReceive('remove')
->once()
->withNoArgs()
->andReturnSelf();
$this->assertInstanceOf(Client::class, $this->client->deleteToken());
}
}
expect($this->client->deleteToken())->toBeInstanceOf(Client::class);
});
function dir($path)
{
return $path === '/some/valid/path';
}
it('clears cached data service when token is changed', function () {
$this->token_mock
->shouldReceive('getAttribute')
->with('hasValidAccessToken')
->andReturnFalse();
function route($name)
{
return $name;
}
$this->token_mock
->shouldReceive('getAttribute')
->with('hasValidRefreshToken')
->andReturnFalse();
$first = $this->client->getDataService();
$new_token = Mockery::mock(Token::class);
$new_token
->shouldReceive('getAttribute')
->with('hasValidAccessToken')
->andReturnFalse();
$new_token
->shouldReceive('getAttribute')
->with('hasValidRefreshToken')
->andReturnFalse();
$this->client->setToken($new_token);
$second = $this->client->getDataService();
expect($second)->not->toBe($first);
});
it('delegates hasValidAccessToken to token', function () {
$this->token_mock
->shouldReceive('getAttribute')
->once()
->with('hasValidAccessToken')
->andReturnTrue();
expect($this->client->hasValidAccessToken())->toBeTrue();
});
it('delegates hasValidRefreshToken to token', function () {
$this->token_mock
->shouldReceive('getAttribute')
->once()
->with('hasValidRefreshToken')
->andReturnFalse();
expect($this->client->hasValidRefreshToken())->toBeFalse();
});
+10 -33
View File
@@ -1,39 +1,16 @@
<?php
namespace Wearepixel\QuickBooks;
use Wearepixel\QuickBooks\Stubs\User;
use Wearepixel\QuickBooks\Token;
/**
* Class HasQuickBooksTokenTest
*/
class HasQuickBooksTokenTest extends TestCase
{
/**
* @var User
*/
protected $user;
beforeEach(function () {
$this->user = new User;
});
protected function setUp(): void
{
parent::setUp();
it('can be constructed', function () {
expect($this->user)->toBeInstanceOf(User::class);
});
$this->user = new User();
}
/**
* @test
*/
public function it_can_be_constructed()
{
$this->assertInstanceOf(User::class, $this->user);
}
/**
* @test
*/
public function it_has_a_hasOne_relationship_to_token()
{
$this->assertInstanceOf(Token::class, $this->user->quickBooksToken()->getModel());
}
}
it('has a hasOne relationship to token', function () {
expect($this->user->quickBooksToken()->getModel())->toBeInstanceOf(Token::class);
});
+20 -98
View File
@@ -1,7 +1,5 @@
<?php
namespace Wearepixel\QuickBooks\Http\Controllers;
use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\View\View;
@@ -9,90 +7,33 @@
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Session\Store;
use Mockery;
use Mockery\Mock;
use QuickBooksOnline\API\DataService\DataService;
use Wearepixel\QuickBooks\Client as QuickBooks;
use Wearepixel\QuickBooks\TestCase;
use Wearepixel\QuickBooks\Http\Controllers\Controller;
/**
* Class ControllerTest
*/
class ControllerTest extends TestCase
{
/**
* @var Controller
*/
protected $controller;
/**
* @var Mock
*/
protected $data_service_mock;
/**
* @var Mock
*/
protected $quickbooks_mock;
/**
* @var Mock
*/
protected $redirector_mock;
/**
* @var Mock
*/
protected $request_mock;
/**
* @var Mock
*/
protected $session_mock;
/**
* @var Mock
*/
protected $view_factory_mock;
/**
* @var Mock
*/
protected $view_mock;
protected function setUp(): void
{
beforeEach(function () {
$this->data_service_mock = Mockery::mock(DataService::class);
$this->quickbooks_mock = Mockery::mock(QuickBooks::class);
$this->redirector_mock = Mockery::mock(Redirector::class);
$this->request_mock = Mockery::mock(Request::class);
$this->session_mock = Mockery::mock(Store::class);
$this->url_generator_mock = Mockery::mock(UrlGenerator::class);
$this->view_factory_mock = Mockery::mock(ViewFactory::class);
$this->view_mock = Mockery::mock(View::class);
$this->controller = new Controller();
}
$this->controller = new Controller;
});
/**
* @test
*/
public function it_can_be_constructed()
{
$this->assertInstanceOf(Controller::class, $this->controller);
}
it('can be constructed', function () {
expect($this->controller)->toBeInstanceOf(Controller::class);
});
/**
* @test
*/
public function it_shows_view_to_connect_if_account_not_linked()
{
it('shows view to disconnect if account linked', function () {
$this->data_service_mock
->shouldReceive('getCompanyInfo')
->once()
->withNoArgs()
->andReturn([
'name' => 'Company',
]);
->andReturn(['name' => 'Company']);
$this->quickbooks_mock
->shouldReceive('hasValidRefreshToken')
@@ -115,22 +56,13 @@ public function it_shows_view_to_connect_if_account_not_linked()
$this->view_mock
->shouldReceive('with')
->once()
->withArgs([
'company',
[
'name' => 'Company',
],
])
->withArgs(['company', ['name' => 'Company']])
->andReturnSelf();
$this->controller->connect($this->quickbooks_mock, $this->view_factory_mock);
}
});
/**
* @test
*/
public function it_shows_view_to_disconnect_if_account_linked()
{
it('shows view to connect if account not linked', function () {
$this->quickbooks_mock
->shouldReceive('hasValidRefreshToken')
->once()
@@ -156,13 +88,9 @@ public function it_shows_view_to_disconnect_if_account_linked()
->andReturnSelf();
$this->controller->connect($this->quickbooks_mock, $this->view_factory_mock);
}
});
/**
* @test
*/
public function it_disconnects_from_quickbooks_when_requested()
{
it('disconnects from quickbooks when requested', function () {
$this->request_mock
->shouldReceive('session')
->once()
@@ -189,15 +117,10 @@ public function it_disconnects_from_quickbooks_when_requested()
$this->quickbooks_mock,
);
$this->assertInstanceOf(RedirectResponse::class, $result);
}
expect($result)->toBeInstanceOf(RedirectResponse::class);
});
/**
* @test
*/
public function it_finishes_connecting_to_quickbooks_when_given_a_valid_token_by_quickbooks()
{
$this->url_generator_mock = Mockery::mock(UrlGenerator::class);
it('finishes connecting to quickbooks when given a valid token', function () {
$realmId = random_int(1, 9999);
$this->quickbooks_mock
@@ -245,6 +168,5 @@ public function it_finishes_connecting_to_quickbooks_when_given_a_valid_token_by
$this->url_generator_mock,
);
$this->assertInstanceOf(RedirectResponse::class, $result);
}
}
expect($result)->toBeInstanceOf(RedirectResponse::class);
});
+12 -66
View File
@@ -1,53 +1,13 @@
<?php
namespace Wearepixel\QuickBooks\Http\Middleware;
use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Contracts\Session\Session;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Mockery;
use Mockery\Mock;
use Wearepixel\QuickBooks\Client as QuickBooks;
use Wearepixel\QuickBooks\TestCase;
use Wearepixel\QuickBooks\Http\Middleware\Filter;
/**
* Class FilterTest
*/
class FilterTest extends TestCase
{
/**
* @var Filter
*/
protected $filter;
/**
* @var Mock
*/
protected $quickbooks_mock;
/**
* @var Mock
*/
protected $redirector_mock;
/**
* @var Mock
*/
protected $request_mock;
/**
* @var Mock
*/
protected $session_mock;
/**
* @var Mock
*/
protected $url_generator_mock;
protected function setUp(): void
{
beforeEach(function () {
$this->quickbooks_mock = Mockery::mock(QuickBooks::class);
$this->redirector_mock = Mockery::mock(Redirector::class);
$this->request_mock = Mockery::mock(Request::class);
@@ -60,23 +20,15 @@ protected function setUp(): void
$this->session_mock,
$this->url_generator_mock,
);
}
});
/**
* @test
*/
public function it_can_be_constructed()
{
$this->assertInstanceOf(Filter::class, $this->filter);
}
it('can be constructed', function () {
expect($this->filter)->toBeInstanceOf(Filter::class);
});
/**
* @test
*/
public function it_just_passes_the_request_to_the_next_middleware_if_account_linked_to_quickbooks()
{
it('passes the request to the next middleware if account linked to quickbooks', function () {
$next_middleware = function ($request) {
$this->assertEquals($this->request_mock, $request);
expect($request)->toBe($this->request_mock);
};
$this->quickbooks_mock
@@ -86,13 +38,9 @@ public function it_just_passes_the_request_to_the_next_middleware_if_account_lin
->andReturnTrue();
$this->filter->handle($this->request_mock, $next_middleware);
}
});
/**
* @test
*/
public function it_redirects_to_quickbooks_connect_route_after_setting_intended_session_if_account_not_linked()
{
it('redirects to quickbooks connect route after setting intended session if account not linked', function () {
$this->request_mock
->shouldReceive('path')
->once()
@@ -118,8 +66,7 @@ public function it_redirects_to_quickbooks_connect_route_after_setting_intended_
->andReturnSelf();
$next_middleware = function ($request) {
// If this is called, then fail test
$this->assertTrue(false);
throw new RuntimeException('Next middleware should not be called');
};
$this->quickbooks_mock
@@ -129,5 +76,4 @@ public function it_redirects_to_quickbooks_connect_route_after_setting_intended_
->andReturnFalse();
$this->filter->handle($this->request_mock, $next_middleware);
}
}
});
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Wearepixel\QuickBooks;
use Wearepixel\QuickBooks\Stubs\User;
function config(string $key): array
{
return [
'keys' => [
'foreign' => 'user_id',
'owner' => 'id',
],
'model' => User::class,
];
}
function route(string $name): string
{
return $name;
}
function dir(string $path): bool
{
return $path === '/some/valid/path';
}
+5
View File
@@ -0,0 +1,5 @@
<?php
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
pest()->use(MockeryPHPUnitIntegration::class);
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace Wearepixel\QuickBooks;
use ArrayAccess;
use Countable;
use Iterator;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Mockery\MockInterface;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
/**
* Class TestCase
*/
abstract class TestCase extends PHPUnitTestCase
{
use MockeryPHPUnitIntegration;
/**
* Helper to allow mocking of iterator classes.
*
* @link https://gist.github.com/VladaHejda/8299871
*
* @return void
*/
protected function mockArrayIterator(MockInterface $mock, array $items)
{
if ($mock instanceof ArrayAccess) {
foreach ($items as $key => $val) {
$mock
->shouldReceive('offsetGet')
->with($key)
->andReturn($val);
$mock
->shouldReceive('offsetExists')
->with($key)
->andReturn(true);
}
$mock->shouldReceive('offsetExists')->andReturn(false);
}
if ($mock instanceof Iterator) {
$counter = 0;
$mock->shouldReceive('rewind')->andReturnUsing(function () use (&$counter) {
$counter = 0;
});
$vals = array_values($items);
$keys = array_values(array_keys($items));
$mock->shouldReceive('valid')->andReturnUsing(function () use (&$counter, $vals) {
return isset($vals[$counter]);
});
$mock->shouldReceive('current')->andReturnUsing(function () use (&$counter, $vals) {
return $vals[$counter];
});
$mock->shouldReceive('key')->andReturnUsing(function () use (&$counter, $keys) {
return $keys[$counter];
});
$mock->shouldReceive('next')->andReturnUsing(function () use (&$counter) {
$counter++;
});
}
if ($mock instanceof Countable) {
$mock->shouldReceive('count')->andReturn(count($items));
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
use Illuminate\Contracts\Support\DeferrableProvider;
use Wearepixel\QuickBooks\Client;
use Wearepixel\QuickBooks\Providers\ClientServiceProvider;
use Wearepixel\QuickBooks\Token;
describe('Token model structure', function () {
it('uses the quickbooks_tokens table', function () {
expect((new Token)->getTable())->toBe('quickbooks_tokens');
});
it('casts expiration fields to datetime', function () {
$casts = (new Token)->getCasts();
expect($casts)
->toHaveKey('access_token_expires_at', 'datetime')
->toHaveKey('refresh_token_expires_at', 'datetime');
});
it('has all expected fillable attributes', function () {
$fillable = (new Token)->getFillable();
expect($fillable)->toEqualCanonicalizing([
'access_token',
'access_token_expires_at',
'realm_id',
'refresh_token',
'refresh_token_expires_at',
'user_id',
]);
});
});
describe('ClientServiceProvider structure', function () {
it('implements DeferrableProvider', function () {
expect(ClientServiceProvider::class)
->toImplement(DeferrableProvider::class);
});
it('declares Client in provides array', function () {
$app = Mockery::mock(\Illuminate\Contracts\Foundation\Application::class);
$app->shouldIgnoreMissing();
$provider = new ClientServiceProvider($app);
expect($provider->provides())->toBe([Client::class]);
});
});
+59 -162
View File
@@ -1,184 +1,97 @@
<?php
namespace Wearepixel\QuickBooks;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Mockery;
use QuickBooksOnline\API\Core\OAuth\OAuth2\OAuth2AccessToken;
use Wearepixel\QuickBooks\Stubs\TokenStub;
use Wearepixel\QuickBooks\Stubs\User;
use Wearepixel\QuickBooks\Token;
/**
* Class TokenTest
*/
class TokenTest extends TestCase
{
/**
* @var Token
*/
protected $token;
beforeEach(function () {
$this->token = new TokenStub;
});
protected function setUp(): void
{
$this->token = new TokenStub();
}
it('can be constructed', function () {
expect($this->token)->toBeInstanceOf(Token::class);
});
/**
* @test
*/
public function it_can_be_constructed()
{
$this->assertInstanceOf(Token::class, $this->token);
}
it('has accessor for valid access token', function () {
expect($this->token->getHasValidAccessTokenAttribute())->not->toBeNull();
});
/**
* @test
*/
public function it_has_accessor_for_valid_access_token()
{
$this->assertNotNull($this->token->getHasValidAccessTokenAttribute());
}
it('has accessor for valid refresh token', function () {
expect($this->token->getHasValidRefreshTokenAttribute())->not->toBeNull();
});
/**
* @test
*/
public function it_has_accessor_for_valid_refresh_token()
{
$this->assertNotNull($this->token->getHasValidRefreshTokenAttribute());
}
it('knows that the access token expires at has to be valid', function () {
expect($this->token->getHasValidAccessTokenAttribute())->toBeFalse();
});
/**
* @test
*/
public function it_knows_that_the_access_token_expires_at_has_to_be_valid()
{
$this->assertFalse($this->token->getHasValidAccessTokenAttribute());
}
it('knows that the refresh token expires at has to be valid', function () {
expect($this->token->getHasValidRefreshTokenAttribute())->toBeFalse();
});
/**
* @test
*/
public function it_knows_that_the_refresh_token_expires_at_has_to_be_valid()
{
$this->assertFalse($this->token->getHasValidRefreshTokenAttribute());
}
/**
* @test
*/
public function it_know_if_access_token_expires_at_is_less_than_now_it_is_not_expired()
{
$now = Carbon::now();
Carbon::setTestNow($now);
it('knows if access token expires at is less than now it is not expired', function () {
Carbon::setTestNow(Carbon::now());
$this->token->access_token_expires_at = Carbon::now()->addSecond();
$this->assertTrue($this->token->getHasValidAccessTokenAttribute());
}
expect($this->token->getHasValidAccessTokenAttribute())->toBeTrue();
});
/**
* @test
*/
public function it_know_if_refresh_token_expires_at_is_less_than_now_it_is_not_expired()
{
$now = Carbon::now();
Carbon::setTestNow($now);
it('knows if refresh token expires at is less than now it is not expired', function () {
Carbon::setTestNow(Carbon::now());
$this->token->refresh_token_expires_at = Carbon::now()->addSecond();
$this->assertTrue($this->token->getHasValidRefreshTokenAttribute());
}
expect($this->token->getHasValidRefreshTokenAttribute())->toBeTrue();
});
/**
* @test
*/
public function it_know_if_access_token_expires_at_is_greater_than_or_equal_to_now_it_is_expired()
{
$now = Carbon::now();
Carbon::setTestNow($now);
it('knows if access token expires at is greater than or equal to now it is expired', function () {
Carbon::setTestNow(Carbon::now());
$this->token->access_token_expires_at = Carbon::now();
$this->assertFalse($this->token->getHasValidAccessTokenAttribute());
expect($this->token->getHasValidAccessTokenAttribute())->toBeFalse();
$this->token->access_token_expires_at->subSecond();
expect($this->token->getHasValidAccessTokenAttribute())->toBeFalse();
});
$this->assertFalse($this->token->getHasValidAccessTokenAttribute());
}
/**
* @test
*/
public function it_know_if_refresh_token_expires_at_is_greater_than_or_equal_to_now_it_is_expired()
{
$now = Carbon::now();
Carbon::setTestNow($now);
it('knows if refresh token expires at is greater than or equal to now it is expired', function () {
Carbon::setTestNow(Carbon::now());
$this->token->refresh_token_expires_at = Carbon::now();
$this->assertFalse($this->token->getHasValidRefreshTokenAttribute());
expect($this->token->getHasValidRefreshTokenAttribute())->toBeFalse();
$this->token->refresh_token_expires_at->subSecond();
expect($this->token->getHasValidRefreshTokenAttribute())->toBeFalse();
});
$this->assertFalse($this->token->getHasValidRefreshTokenAttribute());
}
it('stores the oauth token parts in expected properties', function () {
$oauth_token_mock = Mockery::mock(QuickBooksOnline\API\Core\OAuth\OAuth2\OAuth2AccessToken::class);
/**
* @test
*/
public function it_stores_the_oauth_token_parts_in_expected_properties()
{
$oauth_token_mock = Mockery::mock(OAuth2AccessToken::class);
$oauth_token_mock->shouldReceive('getAccessToken')->once()->withNoArgs()->andReturn('access_token');
$oauth_token_mock->shouldReceive('getAccessTokenExpiresAt')->once()->withNoArgs()->andReturn('2025-06-15 12:00:00');
$oauth_token_mock->shouldReceive('getRealmID')->once()->withNoArgs()->andReturn('realm_id');
$oauth_token_mock->shouldReceive('getRefreshToken')->once()->withNoArgs()->andReturn('refresh_token');
$oauth_token_mock->shouldReceive('getRefreshTokenExpiresAt')->once()->withNoArgs()->andReturn('2025-09-24 12:00:00');
$oauth_token_mock
->shouldReceive('getAccessToken')
->once()
->withNoArgs()
->andReturn('access_token');
$result = $this->token->parseOauthToken($oauth_token_mock);
$oauth_token_mock
->shouldReceive('getAccessTokenExpiresAt')
->once()
->withNoArgs()
->andReturn('now');
expect($result)->toBeInstanceOf(Token::class)
->and($this->token->access_token)->toBe('access_token')
->and($this->token->realm_id)->toBe('realm_id')
->and($this->token->refresh_token)->toBe('refresh_token')
->and($this->token->access_token_expires_at)->toBeInstanceOf(Carbon::class)
->and($this->token->refresh_token_expires_at)->toBeInstanceOf(Carbon::class);
});
$oauth_token_mock
->shouldReceive('getRealmID')
->once()
->withNoArgs()
->andReturn('realm_id');
$oauth_token_mock
->shouldReceive('getRefreshToken')
->once()
->withNoArgs()
->andReturn('refresh_token');
$oauth_token_mock
->shouldReceive('getRefreshTokenExpiresAt')
->once()
->withNoArgs()
->andReturn('now');
$this->token->parseOauthToken($oauth_token_mock);
}
/**
* @test
*/
public function it_allows_itself_to_be_deleted_and_returns_new_token()
{
it('allows itself to be deleted and returns new token', function () {
$this->token->user = Mockery::mock(User::class);
$has_one_mock = Mockery::mock(HasOne::class);
$token_mock = Mockery::mock(Token::class);
$has_one_mock
->shouldReceive('make')
->once()
->withNoArgs()
->andReturn($token_mock);
$has_one_mock->shouldReceive('make')->once()->withNoArgs()->andReturn($token_mock);
$this->token->user
->shouldReceive('quickBooksToken')
@@ -188,25 +101,9 @@ public function it_allows_itself_to_be_deleted_and_returns_new_token()
$this->token->user->id = 1;
$this->assertEquals($token_mock, $this->token->remove());
}
expect($this->token->remove())->toBe($token_mock);
});
/**
* @test
*/
public function it_get_related_user_model_from_configuration()
{
$this->assertInstanceOf(User::class, $this->token->user()->getModel());
}
}
function config($key)
{
return [
'keys' => [
'foreign' => 'user_id',
'owner' => 'id',
],
'model' => User::class,
];
}
it('gets related user model from configuration', function () {
expect($this->token->user()->getModel())->toBeInstanceOf(User::class);
});