wip
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\ApiControllers;
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Http\Resources\HoldingResource;
|
use App\Http\Resources\HoldingResource;
|
||||||
use HackerEsq\FilterModels\FilterModels;
|
use HackerEsq\FilterModels\FilterModels;
|
||||||
@@ -20,4 +21,16 @@ class HoldingController extends ApiController
|
|||||||
|
|
||||||
return HoldingResource::collection($filters->paginated());
|
return HoldingResource::collection($filters->paginated());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function show(Portfolio $portfolio, string $symbol)
|
||||||
|
{
|
||||||
|
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(FilterModels $filters)
|
||||||
|
{
|
||||||
|
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,9 @@ class TransactionController extends ApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function store(TransactionRequest $request)
|
public function store(TransactionRequest $request)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('fullAccess', $request->portfolio);
|
||||||
|
|
||||||
$transaction = Transaction::create($request->validated());
|
$transaction = Transaction::create($request->validated());
|
||||||
|
|
||||||
return TransactionResource::make($transaction);
|
return TransactionResource::make($transaction);
|
||||||
@@ -31,14 +33,14 @@ class TransactionController extends ApiController
|
|||||||
|
|
||||||
public function show(Transaction $transaction)
|
public function show(Transaction $transaction)
|
||||||
{
|
{
|
||||||
Gate::authorize('readOnly', $transaction);
|
Gate::authorize('readOnly', $transaction->portfolio);
|
||||||
|
|
||||||
return TransactionResource::make($transaction);
|
return TransactionResource::make($transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(TransactionRequest $request, Transaction $transaction)
|
public function update(TransactionRequest $request, Transaction $transaction)
|
||||||
{
|
{
|
||||||
Gate::authorize('fullAccess', $transaction);
|
Gate::authorize('fullAccess', $transaction->portfolio);
|
||||||
|
|
||||||
$transaction->update($request->validated());
|
$transaction->update($request->validated());
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ class TransactionController extends ApiController
|
|||||||
|
|
||||||
public function destroy(Transaction $transaction)
|
public function destroy(Transaction $transaction)
|
||||||
{
|
{
|
||||||
Gate::authorize('fullAccess', $transaction);
|
Gate::authorize('fullAccess', $transaction->portfolio);
|
||||||
|
|
||||||
$transaction->delete();
|
$transaction->delete();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;
|
||||||
|
|
||||||
|
class FormRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
|
||||||
|
public function requestOrModelValue($key, $model): mixed
|
||||||
|
{
|
||||||
|
return $this->request->get($key) ?? $this->{$model}?->{$key};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use App\Http\Requests\FormRequest;
|
||||||
|
|
||||||
class PortfolioRequest extends FormRequest
|
class PortfolioRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use App\Models\Portfolio;
|
||||||
|
use App\Http\Requests\FormRequest;
|
||||||
|
use App\Rules\SymbolValidationRule;
|
||||||
|
use App\Rules\QuantityValidationRule;
|
||||||
|
|
||||||
class TransactionRequest extends FormRequest
|
class TransactionRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
|
||||||
|
public ?Portfolio $portfolio;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
@@ -14,15 +19,45 @@ class TransactionRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$this->portfolio = Portfolio::findOrFail($this->requestOrModelValue('portfolio_id', 'transaction'));
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'title' => ['required', 'string', 'min:5', 'max:255'],
|
'portfolio_id' => [], // validated by findOrFail() above
|
||||||
'notes' => ['sometimes', 'nullable', 'string'],
|
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||||
'wishlist' => ['sometimes', 'nullable', 'boolean'],
|
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
||||||
|
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:' . now()->format('Y-m-d')],
|
||||||
|
'quantity' => [
|
||||||
|
'required',
|
||||||
|
'numeric',
|
||||||
|
'min:0',
|
||||||
|
new QuantityValidationRule(
|
||||||
|
$this->portfolio,
|
||||||
|
$this->requestOrModelValue('symbol', 'transaction'),
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction'),
|
||||||
|
$this->requestOrModelValue('date', 'transaction')
|
||||||
|
)
|
||||||
|
],
|
||||||
|
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
||||||
|
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!is_null($this->portfolio)) {
|
if (!is_null($this->transaction)) {
|
||||||
$rules['title'][0] = 'sometimes';
|
$rules['symbol'][0] = 'sometimes';
|
||||||
|
$rules['transaction_type'][0] = 'sometimes';
|
||||||
|
$rules['date'][0] = 'sometimes';
|
||||||
|
$rules['quantity'][0] = 'sometimes';
|
||||||
|
|
||||||
|
if (
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction') == 'SELL'
|
||||||
|
&& $this->requestOrModelValue('sale_price', 'transaction') == null
|
||||||
|
) {
|
||||||
|
$rules['sale_price'][0] = 'required';
|
||||||
|
} elseif (
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction') == 'BUY'
|
||||||
|
&& $this->requestOrModelValue('cost_basis', 'transaction') == null
|
||||||
|
) {
|
||||||
|
$rules['cost_basis'][0] = 'required';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rules;
|
return $rules;
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ namespace App\Models;
|
|||||||
use App\Models\AiChat;
|
use App\Models\AiChat;
|
||||||
use Carbon\CarbonPeriod;
|
use Carbon\CarbonPeriod;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use App\Notifications\InvitedOnboardingNotification;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
class Portfolio extends Model
|
class Portfolio extends Model
|
||||||
@@ -129,6 +131,7 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
// save
|
// save
|
||||||
$portfolio->users()->sync($owner);
|
$portfolio->users()->sync($owner);
|
||||||
|
static::$owner_id = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,4 +256,44 @@ class Portfolio extends Model
|
|||||||
}
|
}
|
||||||
return $formattedHoldings;
|
return $formattedHoldings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a portfolio with a user
|
||||||
|
*
|
||||||
|
* @param string $email
|
||||||
|
* @param boolean $fullAccess
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function share(string $email, bool $fullAccess = false): void
|
||||||
|
{
|
||||||
|
$user = User::firstOrCreate([
|
||||||
|
'email' => $email
|
||||||
|
], [
|
||||||
|
'name' => Str::title(Str::before($email, '@'))
|
||||||
|
]);
|
||||||
|
|
||||||
|
$permissions[$user->id] = [
|
||||||
|
'full_access' => $fullAccess
|
||||||
|
];
|
||||||
|
|
||||||
|
$sync = $this->users()->syncWithoutDetaching($permissions);
|
||||||
|
|
||||||
|
if (!empty($sync['attached'])) {
|
||||||
|
|
||||||
|
foreach($sync['attached'] as $newUserId) {
|
||||||
|
User::find($newUserId)->notify(new InvitedOnboardingNotification($this, auth()->user()));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un-share a portfolio
|
||||||
|
*
|
||||||
|
* @param string $userId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function unShare(string $userId): void
|
||||||
|
{
|
||||||
|
$this->users()->detach($userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected Portfolio $portfolio,
|
protected ?Portfolio $portfolio,
|
||||||
protected string $symbol,
|
protected ?string $symbol,
|
||||||
protected string $transactionType,
|
protected ?string $transactionType,
|
||||||
protected string $date
|
protected ?string $date
|
||||||
) {
|
) {
|
||||||
$this->portfolio = $portfolio;
|
$this->portfolio = $portfolio;
|
||||||
$this->symbol = $symbol;
|
$this->symbol = $symbol;
|
||||||
@@ -34,6 +34,11 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
*/
|
*/
|
||||||
public function validate(string $attribute, mixed $value, \Closure $fail): void
|
public function validate(string $attribute, mixed $value, \Closure $fail): void
|
||||||
{
|
{
|
||||||
|
if (is_null($this->portfolio) || is_null($this->symbol) || is_null($this->transactionType) || is_null($this->date)) {
|
||||||
|
//
|
||||||
|
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->transactionType == 'SELL') {
|
if ($this->transactionType == 'SELL') {
|
||||||
|
|
||||||
$purchase_qty = $this->portfolio->transactions()
|
$purchase_qty = $this->portfolio->transactions()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class PortfolioFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'title' => $this->faker->word,
|
'title' => $this->faker->words(4, true),
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use Livewire\Attributes\Rule;
|
|||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Mary\Traits\Toast;
|
use Mary\Traits\Toast;
|
||||||
use App\Notifications\InvitedOnboardingNotification;
|
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ new class extends Component {
|
|||||||
|
|
||||||
unset($this->permissions[$userId]);
|
unset($this->permissions[$userId]);
|
||||||
|
|
||||||
$this->portfolio->users()->sync($this->permissions);
|
$this->portfolio->unShare($userId);
|
||||||
|
|
||||||
$this->portfolio->refresh();
|
$this->portfolio->refresh();
|
||||||
|
|
||||||
@@ -92,24 +91,7 @@ new class extends Component {
|
|||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
$user = User::firstOrCreate([
|
$this->portfolio->share($this->emailAddress, $this->fullAccess);
|
||||||
'email' => $this->emailAddress
|
|
||||||
], [
|
|
||||||
'name' => Str::title(Str::before($this->emailAddress, '@'))
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->permissions[$user->id] = [
|
|
||||||
'full_access' => $this->fullAccess
|
|
||||||
];
|
|
||||||
|
|
||||||
$sync = $this->portfolio->users()->sync($this->permissions);
|
|
||||||
|
|
||||||
if (!empty($sync['attached'])) {
|
|
||||||
|
|
||||||
foreach($sync['attached'] as $newUserId) {
|
|
||||||
User::find($newUserId)->notify(new InvitedOnboardingNotification($this->portfolio, auth()->user()));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->success(__('Shared portfolio with user'));
|
$this->success(__('Shared portfolio with user'));
|
||||||
$this->portfolio->refresh();
|
$this->portfolio->refresh();
|
||||||
|
|||||||
+5
-5
@@ -7,20 +7,20 @@ use App\Http\ApiControllers\PortfolioController;
|
|||||||
use App\Http\ApiControllers\MarketDataController;
|
use App\Http\ApiControllers\MarketDataController;
|
||||||
use App\Http\ApiControllers\TransactionController;
|
use App\Http\ApiControllers\TransactionController;
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum'])->group(function () {
|
Route::middleware(['auth:sanctum'])->name('api.')->group(function () {
|
||||||
|
|
||||||
// user
|
// user
|
||||||
Route::get('/me', [UserController::class, 'me']);
|
Route::get('/me', [UserController::class, 'me'])->name('me');
|
||||||
|
|
||||||
// portfolio
|
// portfolio
|
||||||
Route::apiResource('/portfolio', PortfolioController::class);
|
Route::apiResource('/portfolio', PortfolioController::class);
|
||||||
|
|
||||||
// transaction
|
// transaction
|
||||||
Route::get('/transaction', [TransactionController::class, 'index']);
|
Route::apiResource('/transaction', TransactionController::class);
|
||||||
|
|
||||||
// holding
|
// holding
|
||||||
Route::get('/holding', [HoldingController::class, 'index']);
|
Route::get('/holding', [HoldingController::class, 'index'])->name('holding.index');
|
||||||
|
|
||||||
// market data
|
// market data
|
||||||
Route::get('/market-data/{symbol}', [MarketDataController::class, 'show']);
|
Route::get('/market-data/{symbol}', [MarketDataController::class, 'show'])->name('market-data.show');
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Api;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class PortfoliosTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
protected Portfolio $portfolio;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// make user
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_list_own_portfolios_with_pagination()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Portfolio::factory(10)->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson(route('api.portfolio.index', ['page' => 1, 'itemsPerPage' => 5]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [['id', 'title', 'owner', 'holdings', 'transactions']],
|
||||||
|
'meta' => ['current_page', 'last_page', 'total'],
|
||||||
|
'links' => ['first', 'last', 'prev', 'next']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_list_others_portfolios()
|
||||||
|
{
|
||||||
|
// create portfolios with existing user
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
Portfolio::factory(10)->create();
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
Portfolio::factory(1)->create();
|
||||||
|
$this->actingAs($user)
|
||||||
|
->getJson(route('api.portfolio.index', ['page' => 1, 'itemsPerPage' => 5]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_access_portfolios_when_unauthenticated()
|
||||||
|
{
|
||||||
|
$this->getJson(route('api.portfolio.index'))->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_a_portfolio()
|
||||||
|
{
|
||||||
|
$data = Portfolio::factory()->make()->toArray();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->postJson(route('api.portfolio.store'), $data)
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonStructure(['id', 'title', 'owner']);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('portfolios', ['title' => $data['title']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_create_portfolio_without_required_fields()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->postJson(route('api.portfolio.store'), [])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_show_a_portfolio()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson(route('api.portfolio.show', $portfolio))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['id', 'title', 'owner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_show_nonexistent_portfolio()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson(route('api.portfolio.show', ['portfolio' => 999]))
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_update_a_portfolio()
|
||||||
|
{
|
||||||
|
$updatedData = ['title' => 'Updated Portfolio Title'];
|
||||||
|
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->putJson(route('api.portfolio.update', $portfolio), $updatedData)
|
||||||
|
->assertOk()
|
||||||
|
->assertJson($updatedData);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('portfolios', $updatedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shared_user_can_update_portfolio()
|
||||||
|
{
|
||||||
|
// create portfolio
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
// share it
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$portfolio->share($otherUser->email, true);
|
||||||
|
|
||||||
|
// shared user tries to update it
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->putJson(route('api.portfolio.update', $portfolio), ['title' => 'A brand new updated title'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonFragment([
|
||||||
|
'title' => 'A brand new updated title'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_removed_user_cannot_update_portfolio()
|
||||||
|
{
|
||||||
|
// create portfolio
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
// share it
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$portfolio->share($otherUser->email, true);
|
||||||
|
|
||||||
|
// unshare it
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$portfolio->unShare($otherUser->id);
|
||||||
|
|
||||||
|
// shared user tries to update it
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->putJson(route('api.portfolio.update', $portfolio), ['Title' => 'A brand new updated title'])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_read_only_user_cannot_update_portfolio()
|
||||||
|
{
|
||||||
|
// create portfolio
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
// share it
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$portfolio->share($otherUser->email, false);
|
||||||
|
|
||||||
|
// shared user tries to update it
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->putJson(route('api.portfolio.update', $portfolio), ['Title' => 'A brand new updated title'])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_update_portfolio_without_permission()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->putJson(route('api.portfolio.update', $portfolio), ['title' => 'New Title'])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_delete_a_portfolio()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->deleteJson(route('api.portfolio.destroy', $portfolio))
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('portfolios', ['id' => $portfolio->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_delete_portfolio_without_permission()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->deleteJson(route('api.portfolio.destroy', $portfolio))
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Api;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class TransactionsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
protected Portfolio $portfolio;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// make user
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
|
||||||
|
// make portfolio
|
||||||
|
$this->portfolio = Portfolio::factory()->makeOne();
|
||||||
|
$this->portfolio->setOwnerIdAttribute($this->user->id);
|
||||||
|
$this->portfolio->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_list_transactions()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Transaction::factory(10)->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson(route('api.transaction.index', ['page' => 1, 'itemsPerPage' => 5]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [['id', 'symbol', 'transaction_type', 'portfolio_id', 'date']],
|
||||||
|
'meta' => ['current_page', 'last_page', 'total'],
|
||||||
|
'links' => ['first', 'last', 'prev', 'next']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_list_others_transactions()
|
||||||
|
{
|
||||||
|
// create transactions with existing user
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
Transaction::factory(10)->create();
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
Transaction::factory(1)->create();
|
||||||
|
$this->actingAs($user)
|
||||||
|
->getJson(route('api.transaction.index', ['page' => 1, 'itemsPerPage' => 5]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_access_transactions_when_unauthenticated()
|
||||||
|
{
|
||||||
|
$this->getJson(route('api.transaction.index'))->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_transaction()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'symbol' => 'AAPL',
|
||||||
|
'portfolio_id' => $this->portfolio->id,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
'quantity' => 10,
|
||||||
|
'date' => now()->toDateString(),
|
||||||
|
'cost_basis' => 150,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->postJson(route('api.transaction.store'), $data)
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'id',
|
||||||
|
'symbol',
|
||||||
|
'portfolio_id',
|
||||||
|
'transaction_type',
|
||||||
|
'quantity',
|
||||||
|
'date',
|
||||||
|
'cost_basis',
|
||||||
|
'sale_price'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_create_transaction_without_required_fields()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->postJson(route('api.transaction.store'), [
|
||||||
|
'portfolio_id' => $this->portfolio->id,
|
||||||
|
'symbol' => null
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['symbol']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_show_a_transaction()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
$transaction = Transaction::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson(route('api.transaction.show', $transaction))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonFragment([
|
||||||
|
'id' => $transaction->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_show_nonexistent_transactions()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson(route('api.transaction.show', ['transaction' => 999]))
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_update_a_transaction()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
$transaction = Transaction::factory()->create();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'symbol' => 'ZZZ',
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
'cost_basis' => 200.19,
|
||||||
|
'quantity' => 5
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->putJson(route('api.transaction.update', $transaction), $data)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonFragment([
|
||||||
|
'symbol' => 'ZZZ',
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
'cost_basis' => 200.19,
|
||||||
|
'quantity' => 5,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shared_user_can_update_transaction()
|
||||||
|
{
|
||||||
|
// create transaction (and portfolio)
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$transaction = Transaction::factory()->create();
|
||||||
|
|
||||||
|
// share it
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$transaction->portfolio->share($otherUser->email, true);
|
||||||
|
|
||||||
|
// shared user tries to update it
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->putJson(route('api.transaction.update', $transaction), ['symbol' => 'ZZZ'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonFragment([
|
||||||
|
'symbol' => 'ZZZ'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_update_transaction_without_permission()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$transaction = Transaction::factory()->create();
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->putJson(route('api.transaction.update', $transaction), ['symbol' => 'AAPL'])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_delete_a_transaction()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$transaction = Transaction::factory()->create();
|
||||||
|
|
||||||
|
$this->deleteJson(route('api.transaction.destroy', $transaction))
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('transactions', ['id' => $transaction->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_delete_transaction_without_permission()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$transaction = Transaction::factory()->create();
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->deleteJson(route('api.transaction.destroy', $transaction))
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,50 +14,45 @@ class ApiTokenPermissionsTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
// public function test_api_tokens_can_be_deleted(): void
|
public function test_api_tokens_can_be_deleted(): void
|
||||||
// {
|
{
|
||||||
// if (! Features::hasApiFeatures()) {
|
if (! Features::hasApiFeatures()) {
|
||||||
// $this->markTestSkipped('API support is not enabled.');
|
$this->markTestSkipped('API support is not enabled.');
|
||||||
// }
|
}
|
||||||
|
|
||||||
// $this->actingAs($user = User::factory()->create());
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
// $token = $user->tokens()->create([
|
$token = $user->tokens()->create([
|
||||||
// 'name' => 'Test Token',
|
'name' => 'Test Token',
|
||||||
// 'token' => Str::random(40),
|
'token' => Str::random(40),
|
||||||
// 'abilities' => ['create', 'read'],
|
'abilities' => [],
|
||||||
// ]);
|
]);
|
||||||
|
|
||||||
// Livewire::test(ApiTokenManager::class)
|
Livewire::test(ApiTokenManager::class)
|
||||||
// ->set(['apiTokenIdBeingDeleted' => $token->id])
|
->set(['apiTokenIdBeingDeleted' => $token->id])
|
||||||
// ->call('deleteApiToken');
|
->call('deleteApiToken');
|
||||||
|
|
||||||
// $this->assertCount(0, $user->fresh()->tokens);
|
$this->assertCount(0, $user->fresh()->tokens);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// public function test_api_tokens_can_be_created(): void
|
public function test_api_tokens_can_be_created(): void
|
||||||
// {
|
{
|
||||||
// if (! Features::hasApiFeatures()) {
|
if (! Features::hasApiFeatures()) {
|
||||||
// $this->markTestSkipped('API support is not enabled.');
|
$this->markTestSkipped('API support is not enabled.');
|
||||||
// }
|
}
|
||||||
|
|
||||||
// $this->actingAs($user = User::factory()->create());
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
// Livewire::test(ApiTokenManager::class)
|
Livewire::test(ApiTokenManager::class)
|
||||||
// ->set(['createApiTokenForm' => [
|
->set(['createApiTokenForm' => [
|
||||||
// 'name' => 'Test Token',
|
'name' => 'Test Token',
|
||||||
// 'permissions' => [
|
'permissions' => [],
|
||||||
// 'read',
|
]])
|
||||||
// 'update',
|
->call('createApiToken');
|
||||||
// ],
|
|
||||||
// ]])
|
|
||||||
// ->call('createApiToken');
|
|
||||||
|
|
||||||
// $this->assertCount(1, $user->fresh()->tokens);
|
$this->assertCount(1, $user->fresh()->tokens);
|
||||||
// $this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
|
$this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
|
||||||
// $this->assertTrue($user->fresh()->tokens->first()->can('read'));
|
}
|
||||||
// $this->assertFalse($user->fresh()->tokens->first()->can('delete'));
|
|
||||||
// }
|
|
||||||
|
|
||||||
// public function test_api_token_permissions_can_be_updated(): void
|
// public function test_api_token_permissions_can_be_updated(): void
|
||||||
// {
|
// {
|
||||||
@@ -76,10 +71,7 @@ class ApiTokenPermissionsTest extends TestCase
|
|||||||
// Livewire::test(ApiTokenManager::class)
|
// Livewire::test(ApiTokenManager::class)
|
||||||
// ->set(['managingPermissionsFor' => $token])
|
// ->set(['managingPermissionsFor' => $token])
|
||||||
// ->set(['updateApiTokenForm' => [
|
// ->set(['updateApiTokenForm' => [
|
||||||
// 'permissions' => [
|
// 'permissions' => [],
|
||||||
// 'delete',
|
|
||||||
// 'missing-permission',
|
|
||||||
// ],
|
|
||||||
// ]])
|
// ]])
|
||||||
// ->call('updateApiToken');
|
// ->call('updateApiToken');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user