Merge pull request #56 from investbrainapp/api-wip

feat: Add Investbrain API capabilities
This commit is contained in:
hackerESQ
2025-01-27 20:32:29 -06:00
committed by GitHub
42 changed files with 1309 additions and 135 deletions
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\ApiControllers;
abstract class Controller
{
//
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\ApiControllers;
use App\Models\Holding;
use App\Models\Portfolio;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use App\Http\Requests\HoldingRequest;
use App\Http\Resources\HoldingResource;
use HackerEsq\FilterModels\FilterModels;
use App\Http\ApiControllers\Controller as ApiController;
class HoldingController extends ApiController
{
public function index(FilterModels $filters)
{
$filters->setQuery(Holding::query());
$filters->setScopes(['myHoldings']);
$filters->setEagerRelations(['market_data', 'transactions']);
$filters->setSearchableColumns(['symbol']);
return HoldingResource::collection($filters->paginated());
}
public function show(Portfolio $portfolio, string $symbol)
{
Gate::authorize('readOnly', $portfolio);
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
return HoldingResource::make($holding);
}
public function update(HoldingRequest $request, Portfolio $portfolio, string $symbol)
{
Gate::authorize('fullAccess', $portfolio);
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
$holding->update($request->validated());
return HoldingResource::make($holding);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Models\MarketData;
use Illuminate\Http\Request;
use App\Http\Resources\MarketDataResource;
use App\Http\ApiControllers\Controller as ApiController;
class MarketDataController extends ApiController
{
public function show(Request $request, string $symbol)
{
return MarketDataResource::make(
MarketData::getMarketData($symbol)
);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Models\Portfolio;
use Illuminate\Support\Facades\Gate;
use HackerEsq\FilterModels\FilterModels;
use App\Http\Resources\PortfolioResource;
use App\Http\Requests\PortfolioRequest;
use App\Http\ApiControllers\Controller as ApiController;
class PortfolioController extends ApiController
{
public function index(FilterModels $filters)
{
$filters->setQuery(Portfolio::query());
$filters->setScopes(['myPortfolios']);
$filters->setEagerRelations(['users', 'transactions', 'holdings']);
$filters->setFilterableRelations(['holdings.symbol']);
$filters->setSearchableColumns(['title', 'notes']);
return PortfolioResource::collection($filters->paginated());
}
public function store(PortfolioRequest $request)
{
$portfolio = Portfolio::create($request->validated());
return PortfolioResource::make($portfolio);
}
public function show(Portfolio $portfolio)
{
Gate::authorize('readOnly', $portfolio);
return PortfolioResource::make($portfolio);
}
public function update(PortfolioRequest $request, Portfolio $portfolio)
{
Gate::authorize('fullAccess', $portfolio);
$portfolio->update($request->validated());
return PortfolioResource::make($portfolio);
}
public function destroy(Portfolio $portfolio)
{
Gate::authorize('fullAccess', $portfolio);
$portfolio->delete();
return response()->noContent();
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\ApiControllers;
use App\Models\Transaction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use HackerEsq\FilterModels\FilterModels;
use App\Http\Requests\TransactionRequest;
use App\Http\Resources\TransactionResource;
use App\Http\ApiControllers\Controller as ApiController;
class TransactionController extends ApiController
{
public function index(FilterModels $filters)
{
$filters->setQuery(Transaction::query());
$filters->setScopes(['myTransactions']);
$filters->setSearchableColumns(['symbol']);
return TransactionResource::collection($filters->paginated());
}
public function store(TransactionRequest $request)
{
Gate::authorize('fullAccess', $request->portfolio);
$transaction = Transaction::create($request->validated());
return TransactionResource::make($transaction);
}
public function show(Transaction $transaction)
{
Gate::authorize('readOnly', $transaction->portfolio);
return TransactionResource::make($transaction);
}
public function update(TransactionRequest $request, Transaction $transaction)
{
Gate::authorize('fullAccess', $transaction->portfolio);
$transaction->update($request->validated());
return TransactionResource::make($transaction);
}
public function destroy(Transaction $transaction)
{
Gate::authorize('fullAccess', $transaction->portfolio);
$transaction->delete();
return response()->noContent();
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\ApiControllers;
use Illuminate\Http\Request;
use App\Http\Resources\UserResource;
use App\Http\ApiControllers\Controller as ApiController;
class UserController extends ApiController
{
public function me(Request $request)
{
return UserResource::make($request->user());
}
}
@@ -6,7 +6,6 @@ use Exception;
use App\Models\User;
use App\Models\ConnectedAccount;
use Illuminate\Support\MessageBag;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Laravel\Socialite\Facades\Socialite;
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Portfolio;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class InvitedOnboardingController extends Controller
{
+2 -3
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Holding;
use App\Models\Portfolio;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class PortfolioController extends Controller
{
@@ -22,9 +23,7 @@ class PortfolioController extends Controller
*/
public function show(Request $request, Portfolio $portfolio)
{
if ($request->user()->cannot('readOnly', $portfolio)) {
abort(403);
}
Gate::authorize('readOnly', $portfolio);
$portfolio->load(['transactions', 'holdings']);
+14
View File
@@ -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};
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\FormRequest;
class HoldingRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'reinvest_dividends' => ['sometimes', 'boolean']
];
return $rules;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\FormRequest;
class PortfolioRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'title' => ['required', 'string', 'min:5', 'max:255'],
'notes' => ['sometimes', 'nullable', 'string'],
'wishlist' => ['sometimes', 'nullable', 'boolean'],
];
if (!is_null($this->portfolio)) {
$rules['title'][0] = 'sometimes';
}
return $rules;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests;
use App\Models\Portfolio;
use App\Http\Requests\FormRequest;
use App\Rules\SymbolValidationRule;
use App\Rules\QuantityValidationRule;
class TransactionRequest extends FormRequest
{
public ?Portfolio $portfolio;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$this->portfolio = Portfolio::findOrFail($this->requestOrModelValue('portfolio_id', 'transaction'));
$rules = [
'portfolio_id' => [], // validated by findOrFail() above
'symbol' => ['required', 'string', new SymbolValidationRule],
'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->transaction)) {
$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;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class HoldingResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'quantity' => $this->quantity,
'reinvest_dividends' => $this->reinvest_dividends,
'average_cost_basis' => $this->average_cost_basis,
'total_cost_basis' => $this->total_cost_basis,
'realized_gain_dollars' => $this->realized_gain_dollars,
'dividends_earned' => $this->dividends_earned,
'splits_synced_at' => $this->splits_synced_at,
'total_market_value' => $this->total_market_value,
'market_gain_dollars' => $this->market_gain_dollars,
'market_gain_percent' => $this->market_gain_percent,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MarketDataResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'symbol' => $this->symbol,
'name' => $this->name,
'market_value' => $this->market_value,
'fifty_two_week_low' => $this->fifty_two_week_low,
'fifty_two_week_high' => $this->fifty_two_week_high,
'last_dividend_date' => $this->last_dividend_date,
'last_dividend_amount' => $this->last_dividend_amount,
'dividend_yield' => $this->dividend_yield,
'market_cap' => $this->market_cap,
'trailing_pe' => $this->trailing_pe,
'forward_pe' => $this->forward_pe,
'book_value' => $this->book_value,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PortfolioResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'notes' => $this->notes,
'wishlist' => $this->wishlist,
'owner' => UserResource::make($this->owner),
'transactions' => TransactionResource::collection($this->whenLoaded('transactions')),
'holdings' => HoldingResource::collection($this->whenLoaded('holdings')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TransactionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'symbol' => $this->symbol,
'portfolio_id' => $this->portfolio_id,
'transaction_type' => $this->transaction_type,
'quantity' => $this->quantity,
'cost_basis' => $this->cost_basis,
'sale_price' => $this->sale_price,
'split' => $this->split,
'reinvested_dividend' => $this->reinvested_dividend,
'date' => $this->date,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'profile_photo_url' => $this->profile_photo_url,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
-5
View File
@@ -36,11 +36,6 @@ class Holding extends Model
'reinvest_dividends' => 'boolean'
];
protected $attributes = [
'realized_gain_dollars' => 0,
'dividends_earned' => 0,
];
/**
* Market data for holding
*
+43
View File
@@ -5,11 +5,13 @@ namespace App\Models;
use App\Models\AiChat;
use Carbon\CarbonPeriod;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use App\Notifications\InvitedOnboardingNotification;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Portfolio extends Model
@@ -129,6 +131,7 @@ class Portfolio extends Model
// save
$portfolio->users()->sync($owner);
static::$owner_id = null;
}
}
@@ -253,4 +256,44 @@ class Portfolio extends Model
}
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);
}
}
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Http\Resources\Json\JsonResource;
class AppServiceProvider extends ServiceProvider
{
@@ -22,6 +23,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
JsonResource::withoutWrapping();
}
}
+2 -7
View File
@@ -43,13 +43,8 @@ class JetstreamServiceProvider extends ServiceProvider
*/
protected function configurePermissions(): void
{
Jetstream::defaultApiTokenPermissions(['read']);
Jetstream::defaultApiTokenPermissions([]);
Jetstream::permissions([
'create',
'read',
'update',
'delete',
]);
Jetstream::permissions([]);
}
}
+9 -4
View File
@@ -13,10 +13,10 @@ class QuantityValidationRule implements ValidationRule
* @return void
*/
public function __construct(
protected Portfolio $portfolio,
protected string $symbol,
protected string $transactionType,
protected string $date
protected ?Portfolio $portfolio,
protected ?string $symbol,
protected ?string $transactionType,
protected ?string $date
) {
$this->portfolio = $portfolio;
$this->symbol = $symbol;
@@ -34,6 +34,11 @@ class QuantityValidationRule implements ValidationRule
*/
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') {
$purchase_qty = $this->portfolio->transactions()
+8 -1
View File
@@ -24,7 +24,8 @@
"robsontenorio/mary": "^1.35",
"scheb/yahoo-finance-api": "^4.11",
"staudenmeir/eloquent-has-many-deep": "^1.20",
"tschucki/alphavantage-laravel": "^0.0"
"tschucki/alphavantage-laravel": "^0.0",
"hackeresq/filter-models": "dev-main"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -37,6 +38,12 @@
"repositories": [
{
"type": "vcs",
"no-api": true,
"url": "https://github.com/hackeresq/filter-models"
},
{
"type": "vcs",
"no-api": true,
"url": "https://github.com/investbrainapp/finnhub-php"
}
],
Generated
+142 -31
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d1b7456f149ebd4a89f5666f931c03fd",
"content-hash": "7b8a88dbb7545ee8284282a6dda2ab3f",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.338.2",
"version": "3.339.0",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "7a52364e053d74363f9976dfb4473bace5b7790e"
"reference": "41bcd4a555649d276c8fbc0bc1738e59fda2221d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7a52364e053d74363f9976dfb4473bace5b7790e",
"reference": "7a52364e053d74363f9976dfb4473bace5b7790e",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/41bcd4a555649d276c8fbc0bc1738e59fda2221d",
"reference": "41bcd4a555649d276c8fbc0bc1738e59fda2221d",
"shasum": ""
},
"require": {
@@ -154,9 +154,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.338.2"
"source": "https://github.com/aws/aws-sdk-php/tree/3.339.0"
},
"time": "2025-01-24T19:09:22+00:00"
"time": "2025-01-27T19:25:50+00:00"
},
{
"name": "bacon/bacon-qr-code",
@@ -491,6 +491,85 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.3",
@@ -1063,7 +1142,7 @@
"version": "dev-master",
"source": {
"type": "git",
"url": "https://github.com/investbrainapp/finnhub-php.git",
"url": "https://github.com/investbrainapp/finnhub-php",
"reference": "1f1b35a0c0a6a68f9a791e3ac5cdb6f44ff69d80"
},
"dist": {
@@ -1115,9 +1194,6 @@
"rest",
"sdk"
],
"support": {
"source": "https://github.com/investbrainapp/finnhub-php/tree/master"
},
"time": "2024-09-13T01:29:18+00:00"
},
{
@@ -1727,6 +1803,41 @@
],
"time": "2023-12-03T19:50:20+00:00"
},
{
"name": "hackeresq/filter-models",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/hackeresq/filter-models",
"reference": "565537120ea01bd73f49051ecde90d05e4127c6b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/hackeresq/filter-models/zipball/565537120ea01bd73f49051ecde90d05e4127c6b",
"reference": "565537120ea01bd73f49051ecde90d05e4127c6b",
"shasum": ""
},
"require": {
"laravel/framework": "^11.9",
"php": "^8.2"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"HackerEsq\\FilterModels\\FilterModelsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"HackerEsq\\FilterModels\\": "src/"
}
},
"description": "Simple package to filter your Laravel models with query parameters",
"time": "2025-01-25T04:44:58+00:00"
},
{
"name": "jfcherng/php-color-output",
"version": "3.0.0",
@@ -3546,31 +3657,32 @@
},
{
"name": "maennchen/zipstream-php",
"version": "3.1.1",
"version": "3.1.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "6187e9cc4493da94b9b63eb2315821552015fca9"
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9",
"reference": "6187e9cc4493da94b9b63eb2315821552015fca9",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.1"
"php-64bit": "^8.2"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^10.0",
"vimeo/psalm": "^5.0"
"phpunit/phpunit": "^11.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
@@ -3611,7 +3723,7 @@
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1"
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
},
"funding": [
{
@@ -3619,7 +3731,7 @@
"type": "github"
}
],
"time": "2024-10-10T12:33:01+00:00"
"time": "2025-01-27T12:07:53+00:00"
},
{
"name": "markbaker/complex",
@@ -4706,19 +4818,20 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.29.8",
"version": "1.29.9",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "089ffdfc04b5fcf25a3503d81a4e589f247e20e3"
"reference": "ffb47b639649fc9c8a6fa67977a27b756592ed85"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/089ffdfc04b5fcf25a3503d81a4e589f247e20e3",
"reference": "089ffdfc04b5fcf25a3503d81a4e589f247e20e3",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/ffb47b639649fc9c8a6fa67977a27b756592ed85",
"reference": "ffb47b639649fc9c8a6fa67977a27b756592ed85",
"shasum": ""
},
"require": {
"composer/pcre": "^3.3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -4805,9 +4918,9 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.8"
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.9"
},
"time": "2025-01-12T03:16:27+00:00"
"time": "2025-01-26T04:55:00+00:00"
},
{
"name": "phpoption/phpoption",
@@ -10963,15 +11076,13 @@
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
"finnhub/client": 20
"finnhub/client": 20,
"hackeresq/filter-models": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3",
"ext-gd": "*",
"ext-mbstring": "*",
"ext-zip": "*"
"php": "^8.2"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
+1 -1
View File
@@ -60,7 +60,7 @@ return [
'features' => [
Features::termsAndPrivacyPolicy(),
Features::profilePhotos(),
// Features::api(),
Features::api(),
// Features::teams(['invitations' => true]),
Features::accountDeletion(),
],
+1 -1
View File
@@ -17,7 +17,7 @@ class PortfolioFactory extends Factory
public function definition(): array
{
return [
'title' => $this->faker->word,
'title' => $this->faker->words(4, true),
'created_at' => now(),
'updated_at' => now(),
];
@@ -23,7 +23,7 @@ class CreateTransactionsTable extends Migration
$table->float('quantity', 12, 4);
$table->float('cost_basis', 12, 4);
$table->float('sale_price', 12, 4)->nullable();
$table->boolean('split')->nullable();
$table->boolean('split')->default(false);
$table->date('date');
$table->timestamps();
});
@@ -20,10 +20,10 @@ class CreateHoldingsTable extends Migration
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
$table->foreignIdFor(MarketData::class, 'symbol');
$table->float('quantity', 12, 4);
$table->float('average_cost_basis', 12, 4);
$table->float('total_cost_basis', 12, 4)->nullable();
$table->float('realized_gain_dollars', 12, 4)->nullable();
$table->float('dividends_earned', 12, 4)->nullable();
$table->float('average_cost_basis', 12, 4)->default(0);
$table->float('total_cost_basis', 12, 4)->default(0);
$table->float('realized_gain_dollars', 12, 4)->default(0);
$table->float('dividends_earned', 12, 4)->default(0);
$table->timestamp('splits_synced_at')->nullable();
$table->timestamps();
});
@@ -12,11 +12,11 @@ return new class extends Migration
public function up(): void
{
Schema::table('holdings', function (Blueprint $table) {
$table->boolean('reinvest_dividends')->nullable()->after('quantity');
$table->boolean('reinvest_dividends')->default(false)->after('quantity');
});
Schema::table('transactions', function (Blueprint $table) {
$table->boolean('reinvested_dividend')->nullable()->after('split');
$table->boolean('reinvested_dividend')->default(false)->after('split');
});
}
+1 -1
View File
@@ -70,7 +70,7 @@
"API Token": "API Token",
"Please copy your new API token. For your security, it won\\'t be shown again.": "Please copy your new API token. For your security, it won\\'t be shown again.",
"API Token Permissions": "API Token Permissions",
"API tokens allow third-party services to authenticate with our application on your behalf.": "API tokens allow third-party services to authenticate with our application on your behalf.",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "API tokens allow third-party services to authenticate with Investbrain on your behalf.",
"Delete API Token": "Delete API Token",
"Are you sure you would like to delete this API token?": "Are you sure you would like to delete this API token?",
"This is a secure area of the application. Please confirm your password before continuing.": "This is a secure area of the application. Please confirm your password before continuing.",
+1 -1
View File
@@ -70,7 +70,7 @@
"API Token": "Token API",
"Please copy your new API token. For your security, it won't be shown again.": "Por favor, copia tu nuevo token API. Por seguridad, no se mostrará nuevamente.",
"API Token Permissions": "Permisos del Token API",
"API tokens allow third-party services to authenticate with our application on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con nuestra aplicación en tu nombre.",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con Investbrain en tu nombre.",
"Delete API Token": "Eliminar Token API",
"Are you sure you would like to delete this API token?": "¿Estás seguro de que deseas eliminar este token API?",
"This is a secure area of the application. Please confirm your password before continuing.": "Esta es un área segura de la aplicación. Por favor, confirma tu contraseña antes de continuar.",
@@ -6,7 +6,7 @@
</x-slot>
<x-slot name="description">
{{ __('API tokens allow third-party services to authenticate with our application on your behalf.') }}
{{ __('API tokens allow third-party services to authenticate with Investbrain on your behalf.') }}
</x-slot>
<x-slot name="form">
@@ -7,7 +7,6 @@ use Livewire\Attributes\Rule;
use Livewire\Volt\Component;
use Illuminate\Support\Collection;
use Mary\Traits\Toast;
use App\Notifications\InvitedOnboardingNotification;
new class extends Component {
@@ -75,7 +74,7 @@ new class extends Component {
unset($this->permissions[$userId]);
$this->portfolio->users()->sync($this->permissions);
$this->portfolio->unShare($userId);
$this->portfolio->refresh();
@@ -92,24 +91,7 @@ new class extends Component {
$this->validate();
$user = User::firstOrCreate([
'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->portfolio->share($this->emailAddress, $this->fullAccess);
$this->success(__('Shared portfolio with user'));
$this->portfolio->refresh();
+24 -4
View File
@@ -1,8 +1,28 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\ApiControllers\UserController;
use App\Http\ApiControllers\HoldingController;
use App\Http\ApiControllers\PortfolioController;
use App\Http\ApiControllers\MarketDataController;
use App\Http\ApiControllers\TransactionController;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::middleware(['auth:sanctum'])->name('api.')->group(function () {
// user
Route::get('/me', [UserController::class, 'me'])->name('me');
// portfolio
Route::apiResource('/portfolio', PortfolioController::class);
// transaction
Route::apiResource('/transaction', TransactionController::class);
// holding
Route::get('/holding', [HoldingController::class, 'index'])->name('holding.index');
Route::get('/holding/{portfolio}/{symbol}', [HoldingController::class, 'show'])->name('holding.show')->scopeBindings();
Route::put('/holding/{portfolio}/{symbol}', [HoldingController::class, 'update'])->name('holding.update')->scopeBindings();
// market data
Route::get('/market-data/{symbol}', [MarketDataController::class, 'show'])->name('market-data.show');
});
+1 -1
View File
@@ -5,8 +5,8 @@ use Illuminate\Support\Facades\Route;
use App\Http\Controllers\HoldingController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\PortfolioController;
use App\Http\Controllers\ConnectedAccountController;
use App\Http\Controllers\TransactionController;
use App\Http\Controllers\ConnectedAccountController;
use App\Http\Controllers\InvitedOnboardingController;
use Laravel\Jetstream\Http\Controllers\Livewire\PrivacyPolicyController;
use Laravel\Jetstream\Http\Controllers\Livewire\TermsOfServiceController;
-1
View File
@@ -1,4 +1,3 @@
*
!public/
!.gitignore
!market_data_seed.csv
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Tests\Api;
use Tests\TestCase;
use App\Models\User;
use App\Models\Holding;
use App\Models\Portfolio;
use App\Models\Transaction;
use Illuminate\Foundation\Testing\RefreshDatabase;
class HoldingsTest 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_holdings()
{
$this->actingAs($this->user);
Transaction::factory(10)->create();
$this->actingAs($this->user)
->getJson(route('api.holding.index', ['page' => 1, 'itemsPerPage' => 5]))
->assertOk()
->assertJsonStructure([
'data' => [['id', 'symbol', 'portfolio_id', 'total_market_value', 'dividends_earned']],
'meta' => ['current_page', 'last_page', 'total'],
'links' => ['first', 'last', 'prev', 'next']
]);
}
public function test_cannot_list_others_holdings()
{
// 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.holding.index', ['page' => 1, 'itemsPerPage' => 5]))
->assertOk()
->assertJsonCount(1, 'data');
}
public function test_cannot_access_holdings_when_unauthenticated()
{
$this->getJson(route('api.holding.index'))->assertUnauthorized();
}
public function test_can_show_a_holding()
{
$this->actingAs($this->user);
$transaction = Transaction::factory()->create();
$holding = Holding::where(['portfolio_id' => $transaction->portfolio->id, 'symbol' => $transaction->symbol])->firstOrFail();
$this->getJson(route('api.holding.show', ['portfolio' => $transaction->portfolio_id, 'symbol' => $transaction->symbol]))
->assertOk()
->assertJsonFragment([
'id' => $holding->id,
]);
}
public function test_cannot_show_nonexistent_holdings()
{
$this->actingAs($this->user)
->getJson(route('api.holding.show', ['portfolio' => 'abc-123-foo-BAR', 'symbol' => 'AAPL']))
->assertNotFound();
}
public function test_can_update_holding_options()
{
$this->actingAs($this->user);
$transaction = Transaction::factory()->create();
$data = [
'reinvest_dividends' => true
];
$this->actingAs($this->user)
->putJson(route('api.holding.update', ['portfolio' => $transaction->portfolio_id, 'symbol' => $transaction->symbol]), $data)
->assertOk()
->assertJsonFragment([
'reinvest_dividends' => true
]);
}
public function test_cannot_update_holding_without_permission()
{
$this->actingAs($this->user);
$transaction = Transaction::factory()->create();
$data = [
'reinvest_dividends' => true
];
$otherUser = User::factory()->create();
$this->actingAs($otherUser)
->putJson(route('api.holding.update', ['portfolio' => $transaction->portfolio_id, 'symbol' => $transaction->symbol]), $data)
->assertForbidden();
}
}
+202
View File
@@ -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();
}
}
+200
View File
@@ -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();
}
}
+32 -40
View File
@@ -14,50 +14,45 @@ class ApiTokenPermissionsTest extends TestCase
{
use RefreshDatabase;
// public function test_api_tokens_can_be_deleted(): void
// {
// if (! Features::hasApiFeatures()) {
// $this->markTestSkipped('API support is not enabled.');
// }
public function test_api_tokens_can_be_deleted(): void
{
if (! Features::hasApiFeatures()) {
$this->markTestSkipped('API support is not enabled.');
}
// $this->actingAs($user = User::factory()->create());
$this->actingAs($user = User::factory()->create());
// $token = $user->tokens()->create([
// 'name' => 'Test Token',
// 'token' => Str::random(40),
// 'abilities' => ['create', 'read'],
// ]);
$token = $user->tokens()->create([
'name' => 'Test Token',
'token' => Str::random(40),
'abilities' => [],
]);
// Livewire::test(ApiTokenManager::class)
// ->set(['apiTokenIdBeingDeleted' => $token->id])
// ->call('deleteApiToken');
Livewire::test(ApiTokenManager::class)
->set(['apiTokenIdBeingDeleted' => $token->id])
->call('deleteApiToken');
// $this->assertCount(0, $user->fresh()->tokens);
// }
$this->assertCount(0, $user->fresh()->tokens);
}
// public function test_api_tokens_can_be_created(): void
// {
// if (! Features::hasApiFeatures()) {
// $this->markTestSkipped('API support is not enabled.');
// }
public function test_api_tokens_can_be_created(): void
{
if (! Features::hasApiFeatures()) {
$this->markTestSkipped('API support is not enabled.');
}
// $this->actingAs($user = User::factory()->create());
$this->actingAs($user = User::factory()->create());
// Livewire::test(ApiTokenManager::class)
// ->set(['createApiTokenForm' => [
// 'name' => 'Test Token',
// 'permissions' => [
// 'read',
// 'update',
// ],
// ]])
// ->call('createApiToken');
Livewire::test(ApiTokenManager::class)
->set(['createApiTokenForm' => [
'name' => 'Test Token',
'permissions' => [],
]])
->call('createApiToken');
// $this->assertCount(1, $user->fresh()->tokens);
// $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'));
// }
$this->assertCount(1, $user->fresh()->tokens);
$this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
}
// public function test_api_token_permissions_can_be_updated(): void
// {
@@ -76,10 +71,7 @@ class ApiTokenPermissionsTest extends TestCase
// Livewire::test(ApiTokenManager::class)
// ->set(['managingPermissionsFor' => $token])
// ->set(['updateApiTokenForm' => [
// 'permissions' => [
// 'delete',
// 'missing-permission',
// ],
// 'permissions' => [],
// ]])
// ->call('updateApiToken');