diff --git a/app/Http/ApiControllers/Controller.php b/app/Http/ApiControllers/Controller.php new file mode 100644 index 0000000..bc0e11d --- /dev/null +++ b/app/Http/ApiControllers/Controller.php @@ -0,0 +1,8 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/ApiControllers/MarketDataController.php b/app/Http/ApiControllers/MarketDataController.php new file mode 100644 index 0000000..31346e9 --- /dev/null +++ b/app/Http/ApiControllers/MarketDataController.php @@ -0,0 +1,21 @@ +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(); + } +} \ No newline at end of file diff --git a/app/Http/ApiControllers/TransactionController.php b/app/Http/ApiControllers/TransactionController.php new file mode 100644 index 0000000..64924bb --- /dev/null +++ b/app/Http/ApiControllers/TransactionController.php @@ -0,0 +1,58 @@ +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(); + } +} \ No newline at end of file diff --git a/app/Http/ApiControllers/UserController.php b/app/Http/ApiControllers/UserController.php new file mode 100644 index 0000000..468ca52 --- /dev/null +++ b/app/Http/ApiControllers/UserController.php @@ -0,0 +1,15 @@ +user()); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/ConnectedAccountController.php b/app/Http/Controllers/ConnectedAccountController.php index 8333b01..0551b89 100644 --- a/app/Http/Controllers/ConnectedAccountController.php +++ b/app/Http/Controllers/ConnectedAccountController.php @@ -6,7 +6,6 @@ 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; diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..71116b2 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -1,8 +1,8 @@ user()->cannot('readOnly', $portfolio)) { - abort(403); - } + Gate::authorize('readOnly', $portfolio); $portfolio->load(['transactions', 'holdings']); diff --git a/app/Http/Requests/FormRequest.php b/app/Http/Requests/FormRequest.php new file mode 100644 index 0000000..d0f8334 --- /dev/null +++ b/app/Http/Requests/FormRequest.php @@ -0,0 +1,14 @@ +request->get($key) ?? $this->{$model}?->{$key}; + } +} \ No newline at end of file diff --git a/app/Http/Requests/HoldingRequest.php b/app/Http/Requests/HoldingRequest.php new file mode 100644 index 0000000..a75d82d --- /dev/null +++ b/app/Http/Requests/HoldingRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + + $rules = [ + 'reinvest_dividends' => ['sometimes', 'boolean'] + ]; + + return $rules; + } +} diff --git a/app/Http/Requests/PortfolioRequest.php b/app/Http/Requests/PortfolioRequest.php new file mode 100644 index 0000000..ab94472 --- /dev/null +++ b/app/Http/Requests/PortfolioRequest.php @@ -0,0 +1,30 @@ +|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; + } +} diff --git a/app/Http/Requests/TransactionRequest.php b/app/Http/Requests/TransactionRequest.php new file mode 100644 index 0000000..c0412d3 --- /dev/null +++ b/app/Http/Requests/TransactionRequest.php @@ -0,0 +1,65 @@ +|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; + } +} diff --git a/app/Http/Resources/HoldingResource.php b/app/Http/Resources/HoldingResource.php new file mode 100644 index 0000000..57cd293 --- /dev/null +++ b/app/Http/Resources/HoldingResource.php @@ -0,0 +1,37 @@ + + */ + 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 + ]; + } +} diff --git a/app/Http/Resources/MarketDataResource.php b/app/Http/Resources/MarketDataResource.php new file mode 100644 index 0000000..b9744aa --- /dev/null +++ b/app/Http/Resources/MarketDataResource.php @@ -0,0 +1,36 @@ + + */ + 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, + ]; + } +} diff --git a/app/Http/Resources/PortfolioResource.php b/app/Http/Resources/PortfolioResource.php new file mode 100644 index 0000000..19d3b8a --- /dev/null +++ b/app/Http/Resources/PortfolioResource.php @@ -0,0 +1,31 @@ + + */ + 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, + ]; + } +} diff --git a/app/Http/Resources/TransactionResource.php b/app/Http/Resources/TransactionResource.php new file mode 100644 index 0000000..49ec5ad --- /dev/null +++ b/app/Http/Resources/TransactionResource.php @@ -0,0 +1,34 @@ + + */ + 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, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..41eeff5 --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,29 @@ + + */ + 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, + ]; + } +} diff --git a/app/Models/Holding.php b/app/Models/Holding.php index b8c7d87..6cb4d43 100644 --- a/app/Models/Holding.php +++ b/app/Models/Holding.php @@ -36,11 +36,6 @@ class Holding extends Model 'reinvest_dividends' => 'boolean' ]; - protected $attributes = [ - 'realized_gain_dollars' => 0, - 'dividends_earned' => 0, - ]; - /** * Market data for holding * diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php index 0660745..2195fdf 100644 --- a/app/Models/Portfolio.php +++ b/app/Models/Portfolio.php @@ -5,11 +5,13 @@ 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 @@ public static function ensurePortfolioHasOwner(self $portfolio) // save $portfolio->users()->sync($owner); + static::$owner_id = null; } } @@ -253,4 +256,44 @@ public function getFormattedHoldings() } 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); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0450ca0..39f79b0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 @@ public function register(): void */ public function boot(): void { - // + JsonResource::withoutWrapping(); } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index f931bf3..f7a2ed0 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -43,13 +43,8 @@ public function boot(): void */ protected function configurePermissions(): void { - Jetstream::defaultApiTokenPermissions(['read']); - - Jetstream::permissions([ - 'create', - 'read', - 'update', - 'delete', - ]); + Jetstream::defaultApiTokenPermissions([]); + + Jetstream::permissions([]); } } diff --git a/app/Rules/QuantityValidationRule.php b/app/Rules/QuantityValidationRule.php index 0dea105..74027bc 100644 --- a/app/Rules/QuantityValidationRule.php +++ b/app/Rules/QuantityValidationRule.php @@ -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 @@ public function __construct( */ 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() diff --git a/composer.json b/composer.json index f8eb719..4995b6e 100644 --- a/composer.json +++ b/composer.json @@ -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" } ], diff --git a/composer.lock b/composer.lock index 1192fc7..4067694 100644 --- a/composer.lock +++ b/composer.lock @@ -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" diff --git a/config/jetstream.php b/config/jetstream.php index 7b9d0f6..b1f2c73 100644 --- a/config/jetstream.php +++ b/config/jetstream.php @@ -60,7 +60,7 @@ 'features' => [ Features::termsAndPrivacyPolicy(), Features::profilePhotos(), - // Features::api(), + Features::api(), // Features::teams(['invitations' => true]), Features::accountDeletion(), ], diff --git a/database/factories/PortfolioFactory.php b/database/factories/PortfolioFactory.php index 85ac7bd..175631d 100644 --- a/database/factories/PortfolioFactory.php +++ b/database/factories/PortfolioFactory.php @@ -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(), ]; diff --git a/database/migrations/2021_02_25_041257_create_transactions_table.php b/database/migrations/2021_02_25_041257_create_transactions_table.php index 7ed2184..f5a7497 100644 --- a/database/migrations/2021_02_25_041257_create_transactions_table.php +++ b/database/migrations/2021_02_25_041257_create_transactions_table.php @@ -23,7 +23,7 @@ public function up() $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(); }); diff --git a/database/migrations/2021_09_06_014744_create_holdings_table.php b/database/migrations/2021_09_06_014744_create_holdings_table.php index c4a9692..e43f690 100644 --- a/database/migrations/2021_09_06_014744_create_holdings_table.php +++ b/database/migrations/2021_09_06_014744_create_holdings_table.php @@ -20,10 +20,10 @@ public function up() $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(); }); diff --git a/database/migrations/2024_10_18_000001_add_reinvestment_columns.php b/database/migrations/2024_10_18_000001_add_reinvestment_columns.php index 28defcc..da2293f 100644 --- a/database/migrations/2024_10_18_000001_add_reinvestment_columns.php +++ b/database/migrations/2024_10_18_000001_add_reinvestment_columns.php @@ -12,11 +12,11 @@ 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'); }); } diff --git a/lang/en.json b/lang/en.json index f40abc4..c2dead1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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.", diff --git a/lang/es.json b/lang/es.json index 13a81b2..04ef548 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/resources/views/api/api-token-manager.blade.php b/resources/views/api/api-token-manager.blade.php index d161ca0..aad3ece 100644 --- a/resources/views/api/api-token-manager.blade.php +++ b/resources/views/api/api-token-manager.blade.php @@ -6,7 +6,7 @@ - {{ __('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.') }} diff --git a/resources/views/livewire/share-portfolio-form.blade.php b/resources/views/livewire/share-portfolio-form.blade.php index f80b021..6f2a55c 100644 --- a/resources/views/livewire/share-portfolio-form.blade.php +++ b/resources/views/livewire/share-portfolio-form.blade.php @@ -7,7 +7,6 @@ use Livewire\Volt\Component; use Illuminate\Support\Collection; use Mary\Traits\Toast; -use App\Notifications\InvitedOnboardingNotification; new class extends Component { @@ -75,7 +74,7 @@ public function deleteUser(string $userId, bool $confirmed = false) unset($this->permissions[$userId]); - $this->portfolio->users()->sync($this->permissions); + $this->portfolio->unShare($userId); $this->portfolio->refresh(); @@ -92,24 +91,7 @@ public function addUser() $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(); diff --git a/routes/api.php b/routes/api.php index ccc387f..32395b5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,28 @@ 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'); +}); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index d961542..0c60cd7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,8 +5,8 @@ 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; diff --git a/storage/app/.gitignore b/storage/app/.gitignore index 5d91b1f..8f4803c 100644 --- a/storage/app/.gitignore +++ b/storage/app/.gitignore @@ -1,4 +1,3 @@ * !public/ !.gitignore -!market_data_seed.csv diff --git a/tests/Api/HoldingsTest.php b/tests/Api/HoldingsTest.php new file mode 100644 index 0000000..ef1a24b --- /dev/null +++ b/tests/Api/HoldingsTest.php @@ -0,0 +1,117 @@ +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(); + } + +} \ No newline at end of file diff --git a/tests/Api/PortfoliosTest.php b/tests/Api/PortfoliosTest.php new file mode 100644 index 0000000..44dfd5b --- /dev/null +++ b/tests/Api/PortfoliosTest.php @@ -0,0 +1,202 @@ +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(); + } +} \ No newline at end of file diff --git a/tests/Api/TransactionsTest.php b/tests/Api/TransactionsTest.php new file mode 100644 index 0000000..c0cfbba --- /dev/null +++ b/tests/Api/TransactionsTest.php @@ -0,0 +1,200 @@ +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(); + } +} \ No newline at end of file diff --git a/tests/ApiTokenPermissionsTest.php b/tests/ApiTokenPermissionsTest.php index 70ac458..9afd10d 100644 --- a/tests/ApiTokenPermissionsTest.php +++ b/tests/ApiTokenPermissionsTest.php @@ -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.'); - // } - - // $this->actingAs($user = User::factory()->create()); - - // $token = $user->tokens()->create([ - // 'name' => 'Test Token', - // 'token' => Str::random(40), - // 'abilities' => ['create', 'read'], - // ]); - - // Livewire::test(ApiTokenManager::class) - // ->set(['apiTokenIdBeingDeleted' => $token->id]) - // ->call('deleteApiToken'); - - // $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.'); - // } - - // $this->actingAs($user = User::factory()->create()); - - // Livewire::test(ApiTokenManager::class) - // ->set(['createApiTokenForm' => [ - // 'name' => 'Test Token', - // 'permissions' => [ - // 'read', - // 'update', - // ], - // ]]) - // ->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')); - // } + 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()); + + $token = $user->tokens()->create([ + 'name' => 'Test Token', + 'token' => Str::random(40), + 'abilities' => [], + ]); + + Livewire::test(ApiTokenManager::class) + ->set(['apiTokenIdBeingDeleted' => $token->id]) + ->call('deleteApiToken'); + + $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.'); + } + + $this->actingAs($user = User::factory()->create()); + + 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); + } // 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');