Implement user API and first tests.

This commit is contained in:
James Cole
2018-03-03 08:12:18 +01:00
parent 60339a0f6a
commit 9475fef8f6
13 changed files with 505 additions and 58 deletions

View File

@@ -29,10 +29,8 @@ use FireflyIII\Helpers\Filter\InternalTransferFilter;
use FireflyIII\Helpers\Filter\NegativeAmountFilter;
use FireflyIII\Helpers\Filter\PositiveAmountFilter;
use FireflyIII\Models\Transaction;
use FireflyIII\Models\TransactionJournal;
use FireflyIII\Models\TransactionType;
use FireflyIII\Repositories\Journal\JournalRepositoryInterface;
use FireflyIII\Services\Internal\Update\JournalUpdateService;
use FireflyIII\Transformers\TransactionTransformer;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@@ -41,6 +39,7 @@ use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use League\Fractal\Resource\Collection as FractalCollection;
use League\Fractal\Resource\Item;
use League\Fractal\Serializer\JsonApiSerializer;
use Log;
use Preferences;
/**
@@ -176,7 +175,6 @@ class TransactionController extends Controller
* @param TransactionRequest $request
*
* @return \Illuminate\Http\JsonResponse
* @throws \FireflyIII\Exceptions\FireflyException
*/
public function store(TransactionRequest $request, JournalRepositoryInterface $repository)
{
@@ -216,19 +214,20 @@ class TransactionController extends Controller
/**
* @param TransactionRequest $request
* @param TransactionJournal $journal
* @param TransactionRequest $request
* @param JournalRepositoryInterface $repository
* @param Transaction $transaction
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(TransactionRequest $request, Transaction $transaction)
public function update(TransactionRequest $request, JournalRepositoryInterface $repository, Transaction $transaction)
{
$data = $request->getAll();
$data['user'] = auth()->user()->id;
/** @var JournalUpdateService $service */
$service = app(JournalUpdateService::class);
$journal = $service->update($transaction->transactionJournal, $data);
Log::debug('Inside transaction update');
$journal = $repository->update($transaction->transactionJournal, $data);
$manager = new Manager();
$baseUrl = $request->getSchemeAndHttpHost() . '/api/v1';

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace FireflyIII\Api\V1\Controllers;
use FireflyIII\Api\V1\Requests\UserRequest;
use FireflyIII\Repositories\User\UserRepositoryInterface;
use FireflyIII\Transformers\UserTransformer;
use FireflyIII\User;
@@ -89,21 +90,28 @@ class UserController extends Controller
*/
public function index(Request $request)
{
$pageSize = intval(Preferences::getForUser(auth()->user(), 'listPageSize', 50)->data);
// user preferences
$pageSize = intval(Preferences::getForUser(auth()->user(), 'listPageSize', 50)->data);
// make manager
$manager = new Manager();
$baseUrl = $request->getSchemeAndHttpHost() . '/api/v1';
$manager->setSerializer(new JsonApiSerializer($baseUrl));
// build collection
$collection = $this->repository->all();
$count = $collection->count();
$users = $collection->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize);
// make paginator:
$paginator = new LengthAwarePaginator($users, $count, $pageSize, $this->parameters->get('page'));
$manager = new Manager();
$baseUrl = $request->getSchemeAndHttpHost() . '/api/v1';
$manager->setSerializer(new JsonApiSerializer($baseUrl));
$paginator->setPath(route('api.v1.users.index') . $this->buildParams());
// make resource
$resource = new FractalCollection($users, new UserTransformer($this->parameters), 'users');
$resource->setPaginator(new IlluminatePaginatorAdapter($paginator));
return response()->json($manager->createData($resource)->toArray());
return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json');
}
/**
@@ -114,35 +122,70 @@ class UserController extends Controller
*/
public function show(Request $request, User $user)
{
// make manager
$manager = new Manager();
//$manager->parseIncludes(['attachments', 'journals', 'user']);
$baseUrl = $request->getSchemeAndHttpHost() . '/api/v1';
$manager->setSerializer(new JsonApiSerializer($baseUrl));
// add include parameter:
$include = $request->get('include') ?? '';
$manager->parseIncludes($include);
// make resource
$resource = new Item($user, new UserTransformer($this->parameters), 'users');
return response()->json($manager->createData($resource)->toArray());
return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json');
}
/**
* @param AccountRequest $request
* @param UserRequest $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(AccountRequest $request)
public function store(UserRequest $request)
{
$data = $request->getAll();
$user = $this->repository->store($data);
// make manager
$manager = new Manager();
$baseUrl = $request->getSchemeAndHttpHost() . '/api/v1';
$manager->setSerializer(new JsonApiSerializer($baseUrl));
// add include parameter:
$include = $request->get('include') ?? '';
$manager->parseIncludes($include);
// make resource
$resource = new Item($user, new UserTransformer($this->parameters), 'users');
return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json');
}
/**
* @param AccountRequest $request
* @param Account $account
* @param UserRequest $request
* @param User $user
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(AccountRequest $request, Account $account)
public function update(UserRequest $request, User $user)
{
$data = $request->getAll();
$user = $this->repository->update($user, $data);
// make manager
$manager = new Manager();
$baseUrl = $request->getSchemeAndHttpHost() . '/api/v1';
$manager->setSerializer(new JsonApiSerializer($baseUrl));
// add include parameter:
$include = $request->get('include') ?? '';
$manager->parseIncludes($include);
// make resource
$resource = new Item($user, new UserTransformer($this->parameters), 'users');
return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json');
}

View File

@@ -334,7 +334,7 @@ class TransactionRequest extends Request
}
/**
* Throws an error when the given opping account (of type $type) is invalid.
* Throws an error when the given opposing account (of type $type) is invalid.
* Empty data is allowed, system will default to cash.
*
* @param Validator $validator
@@ -342,32 +342,32 @@ class TransactionRequest extends Request
* @param int|null $accountId
* @param null|string $accountName
* @param string $idField
* @param string $nameField
*/
protected function opposingAccountExists(Validator $validator, string $type, ?int $accountId, ?string $accountName, string $idField, string $nameField
): void {
protected function opposingAccountExists(Validator $validator, string $type, ?int $accountId, ?string $accountName, string $idField): void {
$accountId = intval($accountId);
$accountName = strval($accountName);
// both empty? done!
if ($accountId < 1 && strlen($accountName) === 0) {
return;
}
// ID belongs to user and is $type account:
/** @var AccountRepositoryInterface $repository */
$repository = app(AccountRepositoryInterface::class);
$repository->setUser(auth()->user());
$set = $repository->getAccountsById([$accountId]);
if ($set->count() === 1) {
/** @var Account $first */
$first = $set->first();
if ($first->accountType->type !== $type) {
$validator->errors()->add($idField, trans('validation.belongs_user'));
if ($accountId !== 0) {
// ID belongs to user and is $type account:
/** @var AccountRepositoryInterface $repository */
$repository = app(AccountRepositoryInterface::class);
$repository->setUser(auth()->user());
$set = $repository->getAccountsById([$accountId]);
if ($set->count() === 1) {
/** @var Account $first */
$first = $set->first();
if ($first->accountType->type !== $type) {
$validator->errors()->add($idField, trans('validation.belongs_user'));
return;
}
// we ignore the account name at this point.
return;
}
// we ignore the account name at this point.
return;
}
// not having an opposing account by this name is NOT a problem.
@@ -390,7 +390,7 @@ class TransactionRequest extends Request
/** @var Transaction $transaction */
$transaction = $this->route()->parameter('transaction');
if (is_null($transaction)) {
return;
return; // @codeCoverageIgnore
}
$data['type'] = strtolower($transaction->transactionJournal->transactionType->type);
}
@@ -407,13 +407,11 @@ class TransactionRequest extends Request
$this->assetAccountExists($validator, $sourceId, $sourceName, $idField, $nameField);
$idField = 'transactions.' . $index . '.destination_id';
$nameField = 'transactions.' . $index . '.destination_name';
$this->opposingAccountExists($validator, AccountType::EXPENSE, $destinationId, $destinationName, $idField, $nameField);
$this->opposingAccountExists($validator, AccountType::EXPENSE, $destinationId, $destinationName, $idField);
break;
case 'deposit':
$idField = 'transactions.' . $index . '.source_id';
$nameField = 'transactions.' . $index . '.source_name';
$this->opposingAccountExists($validator, AccountType::REVENUE, $sourceId, $sourceName, $idField, $nameField);
$this->opposingAccountExists($validator, AccountType::REVENUE, $sourceId, $sourceName, $idField);
$idField = 'transactions.' . $index . '.destination_id';
$nameField = 'transactions.' . $index . '.destination_name';
@@ -447,7 +445,8 @@ class TransactionRequest extends Request
if ($count < 2) {
return;
}
// this is pretty much impossible:
// @codeCoverageIgnoreStart
if (!isset($data['type'])) {
// the journal may exist in the request:
/** @var Transaction $transaction */
@@ -457,6 +456,7 @@ class TransactionRequest extends Request
}
$data['type'] = strtolower($transaction->transactionJournal->transactionType->type);
}
// @codeCoverageIgnoreEnd
// collect all source ID's and destination ID's, if present:
$sources = [];
@@ -487,7 +487,11 @@ class TransactionRequest extends Request
}
break;
default:
throw new FireflyException(sprintf('The validator cannot handle transaction type "%s" in validateSplitAccounts().', $data['type']));
// @codeCoverageIgnoreStart
throw new FireflyException(
sprintf('The validator cannot handle transaction type "%s" in validateSplitAccounts().', $data['type'])
);
// @codeCoverageIgnoreEnd
}
return;

View File

@@ -0,0 +1,89 @@
<?php
/**
* UserRequest.php
* Copyright (c) 2018 thegrumpydictator@gmail.com
*
* This file is part of Firefly III.
*
* Firefly III is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Firefly III is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace FireflyIII\Api\V1\Requests;
use FireflyIII\User;
/**
* Class UserRequest
*/
class UserRequest extends Request
{
/**
* @return bool
*/
public function authorize(): bool
{
// Only allow authenticated users
if (!auth()->check()) {
return false;
}
/** @var User $user */
$user = auth()->user();
if (!$user->hasRole('owner')) {
return false;
}
return true;
}
/**
* @return array
*/
public function getAll(): array
{
$data = [
'email' => $this->string('email'),
'blocked' => $this->boolean('blocked'),
'blocked_code' => $this->string('blocked_code'),
];
return $data;
}
/**
* @return array
*/
public function rules(): array
{
$rules = [
'email' => 'required|email|unique:users,email,',
'blocked' => 'required|boolean',
'blocked_code' => 'in:email_changed',
];
switch ($this->method()) {
default:
break;
case 'PUT':
case 'PATCH':
$user = $this->route()->parameter('user');
$rules['email'] = 'required|email|unique:users,email,' . $user->id;
break;
}
return $rules;
}
}

View File

@@ -95,8 +95,9 @@ class Sandstorm
/** @var User $user */
$user = $repository->store(
[
'blocked' => false,
'blocked_code' => null,
'email' => $email,
'password' => str_random(16),
]
);
Auth::guard($guard)->login($user);

View File

@@ -264,12 +264,12 @@ class UserRepository implements UserRepositoryInterface
*/
public function store(array $data): User
{
$password = bcrypt($data['password'] ?? app('str')->random(16));
return User::create(
[
'email' => $data['email'],
'password' => $password,
'blocked' => $data['blocked'] ?? false,
'blocked_code' => $data['blocked_code'] ?? null,
'email' => $data['email'],
'password' => str_random(24),
]
);
}
@@ -286,6 +286,24 @@ class UserRepository implements UserRepositoryInterface
return;
}
/**
* Update user info.
*
* @param User $user
* @param array $data
*
* @return User
*/
public function update(User $user, array $data): User
{
$this->updateEmail($user, $data['email']);
$user->blocked = $data['blocked'] ?? false;
$user->blocked_code = $data['blocked_code'] ?? null;
$user->save();
return $user;
}
/**
* This updates the users email address. Same as changeEmail just without most logging. This makes sure that the undo/confirm routine can't catch this one.
* The user is NOT blocked.

View File

@@ -31,7 +31,6 @@ use Illuminate\Support\Collection;
*/
interface UserRepositoryInterface
{
/**
* Returns a collection of all users.
*
@@ -159,6 +158,16 @@ interface UserRepositoryInterface
*/
public function unblockUser(User $user): void;
/**
* Update user info.
*
* @param User $user
* @param array $data
*
* @return User
*/
public function update(User $user, array $data): User;
/**
* This updates the users email address. Same as changeEmail just without most logging. This makes sure that the undo/confirm routine can't catch this one.
* The user is NOT blocked.

View File

@@ -26,6 +26,7 @@ namespace FireflyIII\Transformers;
use FireflyIII\Models\Role;
use FireflyIII\User;
use League\Fractal\Resource\Collection as FractalCollection;
use League\Fractal\TransformerAbstract;
use Symfony\Component\HttpFoundation\ParameterBag;
@@ -34,6 +35,19 @@ use Symfony\Component\HttpFoundation\ParameterBag;
*/
class UserTransformer extends TransformerAbstract
{
/**
* List of resources possible to include.
*
* @var array
*/
protected $availableIncludes = ['accounts', 'attachments', 'bills', 'budgets', 'categories', 'piggy_banks', 'tags', 'transactions'];
/**
* List of resources to automatically include
*
* @var array
*/
protected $defaultIncludes = [];
/** @var ParameterBag */
protected $parameters;
@@ -49,6 +63,116 @@ class UserTransformer extends TransformerAbstract
$this->parameters = $parameters;
}
/**
* Include accounts.
*
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includeAccounts(User $user): FractalCollection
{
return $this->collection($user->accounts, new AccountTransformer($this->parameters), 'accounts');
}
/**
* Include attachments.
*
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includeAttachments(User $user): FractalCollection
{
return $this->collection($user->attachments, new AttachmentTransformer($this->parameters), 'attachments');
}
/**
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includeBills(User $user): FractalCollection
{
return $this->collection($user->bills, new BillTransformer($this->parameters), 'bills');
}
/**
* Include budgets.
*
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includeBudgets(User $user): FractalCollection
{
return $this->collection($user->budgets, new BudgetTransformer($this->parameters), 'budgets');
}
/**
* Include categories.
*
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includeCategories(User $user): FractalCollection
{
return $this->collection($user->categories, new CategoryTransformer($this->parameters), 'categories');
}
/**
* Include piggy banks.
*
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includePiggyBanks(User $user): FractalCollection
{
return $this->collection($user->piggyBanks, new PiggyBankTransformer($this->parameters), 'piggy_banks');
}
/**
* Include tags.
*
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includeTags(User $user): FractalCollection
{
return $this->collection($user->tags, new TagTransformer($this->parameters), 'tags');
}
/**
* Include transactions.
*
* @codeCoverageIgnore
*
* @param User $user
*
* @return FractalCollection
*/
public function includeTransactions(User $user): FractalCollection
{
return $this->collection($user->transactions, new TransactionTransformer($this->parameters), 'transactions');
}
/**
* Transform user.
*

View File

@@ -42,13 +42,23 @@ class User extends Authenticatable
{
use Notifiable, HasApiTokens;
/**
* The attributes that should be casted to native types.
*
* @var array
*/
protected $casts
= [
'created_at' => 'datetime',
'updated_at' => 'datetime',
'blocked' => 'boolean',
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['email', 'password', 'blocked', 'blocked_code'];
/**
* The attributes excluded from the model's JSON form.
*
@@ -63,7 +73,6 @@ class User extends Authenticatable
protected $table = 'users';
/**
* @param $guard
* @param string $value
*
* @return User

View File

@@ -61,7 +61,7 @@ Route::group(
['middleware' => ['auth:api', 'bindings'], 'namespace' => 'FireflyIII\Api\V1\Controllers', 'prefix' => 'transactions', 'as' => 'api.v1.transactions.'],
function () {
// Users API routes:
// Transaction API routes:
Route::get('', ['uses' => 'TransactionController@index', 'as' => 'index']);
Route::post('', ['uses' => 'TransactionController@store', 'as' => 'store']);
Route::get('{transaction}', ['uses' => 'TransactionController@show', 'as' => 'show']);

View File

@@ -37,7 +37,6 @@ use Log;
use Tests\TestCase;
/**
* todo test fire of rules with parameter
* Class TransactionControllerTest
*/
class TransactionControllerTest extends TestCase
@@ -1526,6 +1525,46 @@ class TransactionControllerTest extends TestCase
$response->assertStatus(200);
}
/**
* Add opposing account by a new name.
*
* @covers \FireflyIII\Api\V1\Controllers\TransactionController::store
* @covers \FireflyIII\Api\V1\Requests\TransactionRequest
*/
public function testSuccessNewStoreOpposingName()
{
$journal = $this->user()->transactionJournals()->where('transaction_type_id', 1)->first();
$account = $this->user()->accounts()->where('account_type_id', 3)->first();
$journalRepos = $this->mock(JournalRepositoryInterface::class)->makePartial();
$accountRepos = $this->mock(AccountRepositoryInterface::class);
$journalRepos->shouldReceive('setUser')->once();
$accountRepos->shouldReceive('setUser');
$accountRepos->shouldReceive('getAccountsById')->andReturn(new Collection([$account]));
$journalRepos->shouldReceive('store')->andReturn($journal)->once();
$data = [
'description' => 'Some transaction #' . rand(1, 1000),
'date' => '2018-01-01',
'type' => 'withdrawal',
'transactions' => [
[
'amount' => '10',
'currency_id' => 1,
'source_id' => $account->id,
'destination_name' => 'New expense account #' . rand(1, 1000),
],
],
];
// test API
$response = $this->post('/api/v1/transactions', $data, ['Accept' => 'application/json']);
$response->assertStatus(200);
}
/**
* Submit the minimum amount of data required to create a withdrawal.
*
@@ -1574,7 +1613,7 @@ class TransactionControllerTest extends TestCase
public function testSuccessStoreBasic()
{
// default journal:
$journal = $this->user()->transactionJournals()->first();
$journal = $this->user()->transactionJournals()->where('transaction_type_id', 1)->first();
$account = $this->user()->accounts()->where('account_type_id', 3)->first();
$journalRepos = $this->mock(JournalRepositoryInterface::class)->makePartial();
$accountRepos = $this->mock(AccountRepositoryInterface::class);
@@ -1613,7 +1652,7 @@ class TransactionControllerTest extends TestCase
public function testSuccessStoreBasicDeposit()
{
// default journal:
$journal = $this->user()->transactionJournals()->first();
$journal = $this->user()->transactionJournals()->where('transaction_type_id', 2)->first();
$account = $this->user()->accounts()->where('account_type_id', 3)->first();
$journalRepos = $this->mock(JournalRepositoryInterface::class)->makePartial();
$accountRepos = $this->mock(AccountRepositoryInterface::class);
@@ -2330,4 +2369,78 @@ class TransactionControllerTest extends TestCase
$response = $this->post('/api/v1/transactions?include=tags', $data, ['Accept' => 'application/json']);
$response->assertStatus(200);
}
/**
* Fire enough to trigger an update. Since the create code already fires on the Request, no
* need to verify all of that.
*
* @covers \FireflyIII\Api\V1\Controllers\TransactionController::update
* @covers \FireflyIII\Api\V1\Requests\TransactionRequest
*/
public function testUpdateBasicDeposit()
{
$account = $this->user()->accounts()->where('account_type_id', 3)->first();
$repository = $this->mock(JournalRepositoryInterface::class);
$data = [
'description' => 'Some deposit #' . rand(1, 1000),
'date' => '2018-01-01',
'transactions' => [
[
'amount' => '10',
'currency_id' => 1,
'destination_id' => $account->id,
],
],
];
do {
/** @var TransactionJournal $deposit */
$deposit = $this->user()->transactionJournals()->where('transaction_type_id', 2)->first();
$count = $deposit->transactions()->count();
} while ($count !== 2);
$transaction = $deposit->transactions()->first();
$repository->shouldReceive('setUser');
$repository->shouldReceive('update')->andReturn($deposit)->once();
// call API
$response = $this->put('/api/v1/transactions/' . $transaction->id, $data);
$response->assertStatus(200);
}
/**
* Fire enough to trigger an update. Since the create code already fires on the Request, no
* need to verify all of that.
*
* @covers \FireflyIII\Api\V1\Controllers\TransactionController::update
* @covers \FireflyIII\Api\V1\Requests\TransactionRequest
*/
public function testUpdateBasicWithdrawal()
{
$account = $this->user()->accounts()->where('account_type_id', 3)->first();
$repository = $this->mock(JournalRepositoryInterface::class);
$data = [
'description' => 'Some transaction #' . rand(1, 1000),
'date' => '2018-01-01',
'transactions' => [
[
'amount' => '10',
'currency_id' => 1,
'source_id' => $account->id,
],
],
];
do {
/** @var TransactionJournal $withdrawal */
$withdrawal = $this->user()->transactionJournals()->where('transaction_type_id', 1)->first();
$count = $withdrawal->transactions()->count();
} while ($count !== 2);
$transaction = $withdrawal->transactions()->first();
$repository->shouldReceive('setUser');
$repository->shouldReceive('update')->andReturn($withdrawal)->once();
// call API
$response = $this->put('/api/v1/transactions/' . $transaction->id, $data);
$response->assertStatus(200);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* UserControllerTest.php
* Copyright (c) 2018 thegrumpydictator@gmail.com
*
* This file is part of Firefly III.
*
* Firefly III is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Firefly III is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace Tests\Api\V1\Controllers;
use Tests\TestCase;
/**
* Class UserControllerTest
*/
class UserControllerTest extends TestCase
{
}

View File

@@ -40,6 +40,9 @@ use Mockery;
*/
abstract class TestCase extends BaseTestCase
{
/**
* @param User $user
* @param string $range