diff --git a/.gitignore b/.gitignore index 63b50a5..8529e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /.phpunit.cache +/database/testing.sqlite +/tests/Browser/Screenshots +/tests/Browser/Console /node_modules /public/build /public/hot diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 5928ac2..ad7c157 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -170,13 +170,6 @@ protected function isAccessible(User $user, ?string $path = null): bool ## Enums - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - === laravel/core rules === ## Do Things the Laravel Way diff --git a/AGENTS.md b/AGENTS.md index 952f6d5..40419ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,13 +111,6 @@ protected function isAccessible(User $user, ?string $path = null): bool ## Enums - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - === laravel/core rules === ## Do Things the Laravel Way diff --git a/CLAUDE.md b/CLAUDE.md index 952f6d5..40419ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,13 +111,6 @@ protected function isAccessible(User $user, ?string $path = null): bool ## Enums - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - === laravel/core rules === ## Do Things the Laravel Way diff --git a/app/Models/BookCase.php b/app/Models/BookCase.php deleted file mode 100644 index dc8f319..0000000 --- a/app/Models/BookCase.php +++ /dev/null @@ -1,86 +0,0 @@ - 'integer', - 'lat' => 'double', - 'lon' => 'array', - 'digital' => 'boolean', - 'deactivated' => 'boolean', - ]; - - protected static function booted() - { - static::creating(function ($model) { - if (! $model->created_by) { - $model->created_by = auth()->id(); - } - }); - } - - public function scopeActive($query) - { - return $query->where('deactivated', false); - } - - public function registerMediaConversions(?Media $media = null): void - { - $this - ->addMediaConversion('preview') - ->fit(Manipulations::FIT_CROP, 300, 300) - ->nonQueued(); - $this->addMediaConversion('seo') - ->fit(Manipulations::FIT_CROP, 1200, 630) - ->width(1200) - ->height(630); - $this->addMediaConversion('thumb') - ->fit(Manipulations::FIT_CROP, 130, 130) - ->width(130) - ->height(130); - } - - public function registerMediaCollections(): void - { - $this->addMediaCollection('images') - ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); - } - - public function createdBy(): BelongsTo - { - return $this->belongsTo(User::class, 'created_by'); - } - - public function orangePills(): HasMany - { - return $this->hasMany(OrangePill::class); - } -} diff --git a/app/Models/OrangePill.php b/app/Models/OrangePill.php deleted file mode 100644 index a0dd65f..0000000 --- a/app/Models/OrangePill.php +++ /dev/null @@ -1,75 +0,0 @@ - 'integer', - 'user_id' => 'integer', - 'book_case_id' => 'integer', - 'date' => 'datetime', - ]; - - protected static function booted() - { - static::creating(function ($model) { - $model->user->givePoint(new BookCaseOrangePilled($model)); - }); - static::deleted(function ($model) { - $model->user->undoPoint(new BookCaseOrangePilled($model)); - }); - } - - public function registerMediaConversions(?Media $media = null): void - { - $this - ->addMediaConversion('preview') - ->fit(Manipulations::FIT_CROP, 300, 300) - ->nonQueued(); - $this->addMediaConversion('thumb') - ->fit(Manipulations::FIT_CROP, 130, 130) - ->width(130) - ->height(130); - } - - public function registerMediaCollections(): void - { - $this->addMediaCollection('images') - ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function bookCase(): BelongsTo - { - return $this->belongsTo(BookCase::class); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index 288bd27..1b03c14 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,7 +2,6 @@ namespace App\Models; -use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Http\UploadedFile; @@ -19,16 +18,17 @@ use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable implements CipherSweetEncrypted { - use UsesCipherSweet; - use HasFactory; - use Notifiable; - use HasRoles; use HasApiTokens; + use HasFactory; + use HasRoles; + use Notifiable; + use UsesCipherSweet; protected $guarded = []; /** * The attributes that should be hidden for serialization. + * * @var array */ protected $hidden = [ @@ -40,6 +40,7 @@ class User extends Authenticatable implements CipherSweetEncrypted /** * The attributes that should be cast. + * * @var array */ protected $casts = [ @@ -60,7 +61,7 @@ class User extends Authenticatable implements CipherSweetEncrypted public static function configureCipherSweet(EncryptedRow $encryptedRow): void { - $map = (new JsonFieldMap()) + $map = (new JsonFieldMap) ->addTextField('url') ->addTextField('read_key') ->addTextField('wallet_id'); @@ -81,11 +82,6 @@ class User extends Authenticatable implements CipherSweetEncrypted ->addBlindIndex('email', new BlindIndex('email_index')); } - public function orangePills() - { - return $this->hasMany(OrangePill::class); - } - public function meetups() { return $this->belongsToMany(Meetup::class); diff --git a/composer.json b/composer.json index 19d2e6a..7c7e714 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.3", + "pestphp/pest-plugin-browser": "^4.3", "pestphp/pest-plugin-laravel": "^4.0" }, "autoload": { diff --git a/composer.lock b/composer.lock index 4adeb0b..302adad 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": "ad826718b1e72bed521d15e12e5ac92f", + "content-hash": "43906476926f0e958fad36355cd86d4e", "packages": [ { "name": "akuechler/laravel-geoly", @@ -10629,6 +10629,1237 @@ } ], "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.10", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:00:16+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "75ad21574fd632594a2dd914496647816d5106bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "laminas/laminas-diactoros": "^2.3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-16T20:41:23+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.5", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/ae0fd01e16aba336247852df0c3f8c649a31896d", + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.5" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-01T03:55:07+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "7b52598c2e9105ebcddf247fc523161581930367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", + "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T16:33:53+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-19T15:09:56+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, { "name": "archtechx/enums", "version": "v1.1.2", @@ -10775,6 +12006,50 @@ ], "time": "2026-03-29T15:46:14+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.6", @@ -11356,6 +12631,64 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel-lang/config", "version": "1.17.0", @@ -12414,6 +13747,90 @@ }, "time": "2025-10-20T09:56:46+00:00" }, + { + "name": "league/uri-components", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "shasum": "" + }, + "require": { + "league/uri": "^7.8.1", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -12910,6 +14327,89 @@ ], "time": "2026-04-10T17:20:19+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/b6e76d3e4a2f81da9f050ec54be2a29b402287c4", + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.4", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.4.5", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.8|^8.0.5" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.15", + "nunomaduro/collision": "^8.9.3", + "orchestra/testbench": "^10.11.0", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-laravel": "^4.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-08T21:04:12+00:00" + }, { "name": "pestphp/pest-plugin-laravel", "version": "v4.1.0", @@ -13892,6 +15392,78 @@ ], "time": "2026-04-18T06:12:49+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + }, + "time": "2025-08-27T21:33:23+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", diff --git a/config/permission.php b/config/permission.php index f39f6b5..f643470 100644 --- a/config/permission.php +++ b/config/permission.php @@ -1,5 +1,9 @@ [ @@ -13,7 +17,7 @@ return [ * `Spatie\Permission\Contracts\Permission` contract. */ - 'permission' => Spatie\Permission\Models\Permission::class, + 'permission' => Permission::class, /* * When using the "HasRoles" trait from this package, we need to know which @@ -24,7 +28,7 @@ return [ * `Spatie\Permission\Contracts\Role` contract. */ - 'role' => Spatie\Permission\Models\Role::class, + 'role' => Role::class, ], @@ -136,7 +140,7 @@ return [ /* * The class to use to resolve the permissions team id */ - 'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class, + 'team_resolver' => DefaultTeamResolver::class, /* * Passport Client Credentials Grant @@ -183,7 +187,7 @@ return [ * When permissions or roles are updated the cache is flushed automatically. */ - 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + 'expiration_time' => DateInterval::createFromDateString('24 hours'), /* * The cache key used to store all permissions. @@ -199,4 +203,13 @@ return [ 'store' => 'default', ], + + /* + * Testing-mode flag consumed by the package's migration to add team-related + * columns to the roles table even when teams are disabled. Required for + * the unique index on (team_foreign_key, name, guard_name) to exist on + * SQLite-backed test databases. + */ + + 'testing' => env('PERMISSION_TESTING', false), ]; diff --git a/database/factories/BookCaseFactory.php b/database/factories/BookCaseFactory.php deleted file mode 100644 index 4e5cabb..0000000 --- a/database/factories/BookCaseFactory.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -class BookCaseFactory extends Factory -{ - protected $model = BookCase::class; - - public function definition(): array - { - return [ - 'title' => 'Bitcoin Bücherregal '.fake()->unique()->numberBetween(1, 99999), - 'latitude' => fake()->latitude(47.0, 55.0), - 'longitude' => fake()->longitude(5.0, 16.0), - 'address' => fake()->streetAddress().', '.fake()->postcode().' '.fake()->city(), - 'type' => fake()->randomElement(['public', 'private']), - 'open' => fake()->randomElement(['24/7', 'Mo-Fr 09:00-18:00', 'Wochenenden', null]), - 'comment' => fake()->boolean(60) ? fake()->sentence() : null, - 'contact' => fake()->boolean(50) ? fake()->email() : null, - 'bcz' => null, - 'digital' => fake()->boolean(20), - 'icontype' => 'default', - 'deactivated' => false, - 'deactreason' => '', - 'entrytype' => fake()->randomElement(['public', 'private']), - 'homepage' => fake()->boolean(40) ? fake()->url() : null, - 'created_by' => User::factory(), - ]; - } -} diff --git a/database/factories/OrangePillFactory.php b/database/factories/OrangePillFactory.php deleted file mode 100644 index ab1da7c..0000000 --- a/database/factories/OrangePillFactory.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -class OrangePillFactory extends Factory -{ - protected $model = OrangePill::class; - - public function definition(): array - { - return [ - 'user_id' => User::factory(), - 'book_case_id' => BookCase::factory(), - 'date' => fake()->dateTimeBetween('-1 year', 'now'), - 'amount' => fake()->numberBetween(1, 21), - 'comment' => fake()->boolean(60) ? fake()->sentence() : null, - 'nostr_status' => NostrHelper::fakeNostrEventStatus(), - ]; - } -} diff --git a/database/migrations/2023_03_14_105504_alter_lnbits_field_on_users_table.php b/database/migrations/2023_03_14_105504_alter_lnbits_field_on_users_table.php index 427cea5..27e9eeb 100644 --- a/database/migrations/2023_03_14_105504_alter_lnbits_field_on_users_table.php +++ b/database/migrations/2023_03_14_105504_alter_lnbits_field_on_users_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ /** * Run the migrations. */ @@ -12,8 +13,9 @@ return new class extends Migration { { Schema::table('users', function (Blueprint $table) { $table->json('lnbits') - ->default('{"read_key":null,"url":null,"wallet_id":null}') - ->change(); + ->nullable() + ->default('{"read_key":null,"url":null,"wallet_id":null}') + ->change(); }); } diff --git a/database/migrations/2026_05_02_213351_drop_orange_pills_and_book_cases_tables.php b/database/migrations/2026_05_02_213351_drop_orange_pills_and_book_cases_tables.php new file mode 100644 index 0000000..727686f --- /dev/null +++ b/database/migrations/2026_05_02_213351_drop_orange_pills_and_book_cases_tables.php @@ -0,0 +1,28 @@ +whereIn('model_type', [ + 'App\\Models\\OrangePill', + 'App\\Models\\BookCase', + ]) + ->delete(); + + Schema::dropIfExists('orange_pills'); + Schema::dropIfExists('book_cases'); + } + + public function down(): void + { + throw new RuntimeException( + 'OrangePill and BookCase features were removed permanently; this migration is not reversible.' + ); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 786a9c5..aa82402 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use App\Models\BitcoinEvent; -use App\Models\BookCase; use App\Models\Category; use App\Models\City; use App\Models\Country; @@ -19,7 +18,6 @@ use App\Models\LibraryItem; use App\Models\LoginKey; use App\Models\Meetup; use App\Models\MeetupEvent; -use App\Models\OrangePill; use App\Models\Participant; use App\Models\Podcast; use App\Models\ProjectProposal; @@ -67,9 +65,6 @@ class DatabaseSeeder extends Seeder $lecturers = Lecturer::factory()->count(15) ->recycle($users) ->create(); - $bookCases = BookCase::factory()->count(25) - ->recycle($users) - ->create(); SelfHostedService::factory()->count(10) ->recycle($users) ->create(); @@ -117,12 +112,6 @@ class DatabaseSeeder extends Seeder ->recycle($lecturers) ->recycle($users) ->create(); - OrangePill::withoutEvents(function () use ($users, $bookCases) { - OrangePill::factory()->count(50) - ->recycle($users) - ->recycle($bookCases) - ->create(); - }); $proposals = ProjectProposal::factory()->count(8) ->recycle($users) ->create(); diff --git a/einundzwanzig_app_testing b/einundzwanzig_app_testing deleted file mode 100644 index ad01e70..0000000 Binary files a/einundzwanzig_app_testing and /dev/null differ diff --git a/package.json b/package.json index 31fa341..0623180 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,8 @@ "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", "lightningcss-linux-x64-gnu": "^1.29.1" + }, + "devDependencies": { + "playwright": "^1.59" } } diff --git a/phpunit.xml b/phpunit.xml index 94a2530..957573f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,9 @@ tests/Feature + + tests/Browser + resources/views @@ -25,7 +28,9 @@ - + + + @@ -33,5 +38,8 @@ + + + diff --git a/tests/Browser/AuthFlowTest.php b/tests/Browser/AuthFlowTest.php new file mode 100644 index 0000000..c203132 --- /dev/null +++ b/tests/Browser/AuthFlowTest.php @@ -0,0 +1,15 @@ +assertSee('Login with lightning') + ->assertSee('Bitcoin, not blockchain') + ->assertNoJavaScriptErrors(); +}); + +it('renders the registration page', function () { + $page = visit('/register'); + + $page->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/CrudFlowTest.php b/tests/Browser/CrudFlowTest.php new file mode 100644 index 0000000..c16c947 --- /dev/null +++ b/tests/Browser/CrudFlowTest.php @@ -0,0 +1,42 @@ +create(['code' => 'de']); + City::factory()->create(['country_id' => $country->id]); + + $page = visit('/de/meetup-create'); + + $page->assertSee('Meetup') + ->assertNoJavaScriptErrors(); +}); + +it('lets an authenticated user open the service-create page', function () { + actingAsUser(); + + $page = visit('/de/service-create'); + + $page->assertSee('Service') + ->assertNoJavaScriptErrors(); +}); + +it('lets an authenticated user open the lecturer-create page', function () { + actingAsUser(); + + $page = visit('/de/lecturer-create'); + + $page->assertSee('Lecturer') + ->assertNoJavaScriptErrors(); +}); + +it('opens settings/profile for an authenticated user', function () { + actingAsUser(['name' => 'Browser Tester']); + + $page = visit('/de/settings/profile'); + + $page->assertSee('Browser Tester') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/SmokeTest.php b/tests/Browser/SmokeTest.php new file mode 100644 index 0000000..ce79a29 --- /dev/null +++ b/tests/Browser/SmokeTest.php @@ -0,0 +1,34 @@ +create(['code' => 'de']); + $city = City::factory()->create(['country_id' => $country->id]); + Venue::factory()->create(['city_id' => $city->id]); + Meetup::factory()->create(['city_id' => $city->id, 'visible_on_map' => true]); + Lecturer::factory()->create(); + SelfHostedService::factory()->create(); +}); + +it('loads all listed public pages without console errors or JS errors', function () { + $pages = visit([ + '/welcome', + '/login', + '/register', + '/forgot-password', + '/de/meetups', + '/de/courses', + '/de/lecturers', + '/de/cities', + '/de/venues', + '/de/services', + ]); + + $pages->assertNoSmoke(); +}); diff --git a/tests/Feature/Api/HighscoreApiTest.php b/tests/Feature/Api/HighscoreApiTest.php new file mode 100644 index 0000000..e22f7c4 --- /dev/null +++ b/tests/Feature/Api/HighscoreApiTest.php @@ -0,0 +1,71 @@ +create(['satoshis' => 100, 'achieved_at' => now()->subHours(1)]); + Highscore::factory()->create(['satoshis' => 5000, 'achieved_at' => now()->subHours(2)]); + Highscore::factory()->create(['satoshis' => 1000, 'achieved_at' => now()->subHours(3)]); + + $response = $this->getJson('/api/highscores'); + + $response->assertSuccessful(); + $data = $response->json('data'); + expect(collect($data)->pluck('satoshis')->all())->toBe([5000, 1000, 100]); +}); + +it('accepts a valid highscore submission', function () { + $payload = [ + 'npub' => 'npub1'.str_repeat('a', 58), + 'name' => 'Tester', + 'satoshis' => 1234, + 'blocks' => 5, + 'datetime' => now()->subDay()->toIso8601String(), + ]; + + $this->postJson('/api/highscores', $payload) + ->assertStatus(202) + ->assertJsonPath('data.satoshis', 1234) + ->assertJsonPath('data.name', 'Tester'); + + expect(Highscore::query()->where('npub', $payload['npub'])->exists())->toBeTrue(); +}); + +it('rejects a highscore submission missing npub', function () { + $this->postJson('/api/highscores', [ + 'satoshis' => 1234, + 'blocks' => 5, + 'datetime' => now()->toIso8601String(), + ])->assertUnprocessable() + ->assertJsonValidationErrors(['npub']); +}); + +it('rejects a highscore submission with an npub that does not start with npub1', function () { + $this->postJson('/api/highscores', [ + 'npub' => 'nsec1'.str_repeat('a', 58), + 'satoshis' => 1234, + 'blocks' => 5, + 'datetime' => now()->toIso8601String(), + ])->assertUnprocessable() + ->assertJsonValidationErrors(['npub']); +}); + +it('rejects a highscore submission with negative satoshis', function () { + $this->postJson('/api/highscores', [ + 'npub' => 'npub1'.str_repeat('b', 58), + 'satoshis' => -10, + 'blocks' => 5, + 'datetime' => now()->toIso8601String(), + ])->assertUnprocessable() + ->assertJsonValidationErrors(['satoshis']); +}); + +it('rejects a highscore submission with an invalid datetime', function () { + $this->postJson('/api/highscores', [ + 'npub' => 'npub1'.str_repeat('c', 58), + 'satoshis' => 100, + 'blocks' => 5, + 'datetime' => 'not-a-date', + ])->assertUnprocessable() + ->assertJsonValidationErrors(['datetime']); +}); diff --git a/tests/Feature/Api/JsonFeedTest.php b/tests/Feature/Api/JsonFeedTest.php new file mode 100644 index 0000000..25548c6 --- /dev/null +++ b/tests/Feature/Api/JsonFeedTest.php @@ -0,0 +1,30 @@ +create(['nostr' => 'npub1'.str_repeat('a', 58)]); + User::factory()->create(['nostr' => 'npub1'.str_repeat('b', 58)]); + User::factory()->create(['nostr' => null]); + + $response = $this->getJson('/api/nostrplebs'); + + $response->assertSuccessful(); + expect($response->json()) + ->toHaveCount(2) + ->each->toStartWith('npub1'); +}); + +it('returns bindle-type library items in /api/bindles', function () { + LibraryItem::factory()->create(['type' => 'bindle', 'name' => 'My Bindle']); + LibraryItem::factory()->create(['type' => 'article', 'name' => 'My Article']); + + $response = $this->getJson('/api/bindles'); + + $response->assertSuccessful(); + $names = collect($response->json())->pluck('name'); + expect($names->all()) + ->toContain('My Bindle') + ->not->toContain('My Article'); +}); diff --git a/tests/Feature/Api/MeetupApiTest.php b/tests/Feature/Api/MeetupApiTest.php new file mode 100644 index 0000000..179dc16 --- /dev/null +++ b/tests/Feature/Api/MeetupApiTest.php @@ -0,0 +1,96 @@ +create(['code' => 'de']); + $this->city = City::factory()->create(['country_id' => $country->id]); +}); + +it('returns visible meetups in JSON shape on GET /api/meetups', function () { + Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'visible_on_map' => true, + 'name' => 'Visible Meetup', + 'community' => 'einundzwanzig', + ]); + Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'visible_on_map' => false, + 'name' => 'Hidden Meetup', + ]); + + $response = $this->getJson('/api/meetups'); + + $response->assertSuccessful(); + $names = collect($response->json())->pluck('name'); + expect($names->all())->toContain('Visible Meetup') + ->not->toContain('Hidden Meetup'); +}); + +it('includes intro and logo when ?withIntro=1&withLogos=1 is provided', function () { + Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'visible_on_map' => true, + 'name' => 'WithExtras', + 'intro' => 'Some intro text', + ]); + + $response = $this->getJson('/api/meetups?withIntro=1&withLogos=1'); + + $response->assertSuccessful(); + $payload = collect($response->json())->firstWhere('name', 'WithExtras'); + expect($payload) + ->intro->toBe('Some intro text') + ->logo->toBeString(); +}); + +it('returns einundzwanzig community meetups in BTC-Map format', function () { + Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'community' => 'einundzwanzig', + 'name' => 'BTC Map Meetup', + ]); + Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'community' => 'other', + 'name' => 'Excluded Meetup', + ]); + + $response = $this->getJson('/api/btc-map-communities'); + + $response->assertSuccessful() + ->assertJsonStructure([['id', 'tags' => ['type', 'name']]]); + + $names = collect($response->json())->pluck('tags.name'); + expect($names->all()) + ->toContain('BTC Map Meetup') + ->not->toContain('Excluded Meetup'); +}); + +it('returns meetup events as JSON on GET /api/meetup-events', function () { + $meetup = Meetup::factory()->create(['city_id' => $this->city->id]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->addDay(), + ]); + + $response = $this->getJson('/api/meetup-events'); + + $response->assertSuccessful(); + expect($response->json())->toBeArray()->not->toBeEmpty(); +}); + +it('filters /api/meetup-events by date when one is supplied', function () { + $meetup = Meetup::factory()->create(['city_id' => $this->city->id]); + MeetupEvent::factory()->create(['meetup_id' => $meetup->id, 'start' => now()->addMonth()->startOfMonth()->addDays(5)]); + + $date = now()->addMonth()->startOfMonth()->format('Y-m-d'); + $response = $this->getJson("/api/meetup-events/{$date}"); + + $response->assertSuccessful(); + expect($response->json())->toBeArray()->not->toBeEmpty(); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..441c428 --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,43 @@ + null]); + + $verifyUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)], + ); + + $this->get($verifyUrl)->assertRedirect(); + + expect($user->refresh()->hasVerifiedEmail())->toBeTrue(); + Event::assertDispatched(Verified::class); +}); + +it('does not re-fire the Verified event when email is already verified', function () { + Event::fake(); + $user = actingAsUser(['email_verified_at' => now()]); + + $verifyUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)], + ); + + $this->get($verifyUrl)->assertRedirect(); + + Event::assertNotDispatched(Verified::class); +}); + +it('rejects an invalid signed URL with 403', function () { + actingAsUser(['email_verified_at' => null]); + + $this->get(route('verification.verify', ['id' => 1, 'hash' => 'invalid'])) + ->assertForbidden(); +}); diff --git a/tests/Feature/Auth/LnurlAuthTest.php b/tests/Feature/Auth/LnurlAuthTest.php new file mode 100644 index 0000000..0ab4105 --- /dev/null +++ b/tests/Feature/Auth/LnurlAuthTest.php @@ -0,0 +1,60 @@ +get('/api/lnurl-auth-callback') + ->assertStatus(400) + ->assertJson([ + 'status' => 'ERROR', + 'reason' => 'Invalid request parameters', + ]); +}); + +it('returns invalid request parameters when k1 is the wrong length', function () { + $this->getJson('/api/lnurl-auth-callback?'.http_build_query([ + 'k1' => 'tooshort', + 'sig' => str_repeat('a', 128), + 'key' => str_repeat('a', 64), + ])) + ->assertStatus(400) + ->assertJson(['status' => 'ERROR']); +}); + +it('returns invalid request parameters when k1 is not hex', function () { + $this->getJson('/api/lnurl-auth-callback?'.http_build_query([ + 'k1' => str_repeat('Z', 64), + 'sig' => str_repeat('a', 128), + 'key' => str_repeat('a', 64), + ])) + ->assertStatus(400) + ->assertJson(['status' => 'ERROR']); +}); + +it('returns no error from /api/check-auth-error when k1 is missing', function () { + $this->postJson('/api/check-auth-error', []) + ->assertSuccessful() + ->assertJson(['error' => null]); +}); + +it('returns no error from /api/check-auth-error when a recent LoginKey exists', function () { + $user = User::factory()->create(); + $loginKey = LoginKey::factory()->create([ + 'user_id' => $user->id, + 'created_at' => now(), + ]); + + $this->postJson('/api/check-auth-error', ['k1' => $loginKey->k1]) + ->assertSuccessful() + ->assertJson(['error' => null]); +}); + +it('returns a session-expired error when no LoginKey exists and elapsed_seconds exceeds 300', function () { + $this->postJson('/api/check-auth-error', [ + 'k1' => str_repeat('a', 64), + 'elapsed_seconds' => 400, + ]) + ->assertSuccessful() + ->assertJson(['error' => 'Session expired. Please try again.']); +}); diff --git a/tests/Feature/Auth/NostrLoginTest.php b/tests/Feature/Auth/NostrLoginTest.php new file mode 100644 index 0000000..64ab409 --- /dev/null +++ b/tests/Feature/Auth/NostrLoginTest.php @@ -0,0 +1,37 @@ +dispatch('nostrLoggedIn', pubkey: $pubkey) + ->assertRedirect(); + + $user = User::query()->where('nostr', $pubkey)->first(); + expect($user)->not->toBeNull() + ->and((bool) $user->is_lecturer)->toBeTrue() + ->and($user->email)->toEndWith('@portal.einundzwanzig.space'); + + Queue::assertPushed(FetchNostrProfileJob::class); + expect(auth()->id())->toBe($user->id); +}); + +it('logs in an existing user without creating a duplicate when their pubkey is already known', function () { + Queue::fake(); + $pubkey = 'npub1'.str_repeat('a', 58); + $existing = User::factory()->create(['nostr' => $pubkey]); + + Livewire::test('auth.login') + ->dispatch('nostrLoggedIn', pubkey: $pubkey) + ->assertRedirect(); + + expect(User::query()->where('nostr', $pubkey)->count())->toBe(1); + expect(auth()->id())->toBe($existing->id); + Queue::assertPushed(FetchNostrProfileJob::class); +}); diff --git a/tests/Feature/Cities/CityCrudTest.php b/tests/Feature/Cities/CityCrudTest.php new file mode 100644 index 0000000..3936f74 --- /dev/null +++ b/tests/Feature/Cities/CityCrudTest.php @@ -0,0 +1,82 @@ +country = Country::factory()->create(['code' => 'de']); +}); + +it('creates a City with valid data', function () { + actingAsUser(); + + Livewire::test('cities.create') + ->set('name', 'Berlin') + ->set('country_id', $this->country->id) + ->set('latitude', 52.52) + ->set('longitude', 13.405) + ->call('createCity') + ->assertHasNoErrors(); + + expect(City::query()->where('name', 'Berlin')->exists())->toBeTrue(); +}); + +it('rejects city creation without a name (country_id is preset by mount() from the route prefix)', function () { + actingAsUser(); + + Livewire::test('cities.create') + ->call('createCity') + ->assertHasErrors(['name' => 'required']); +}); + +it('rejects city creation when country_id is explicitly cleared', function () { + actingAsUser(); + + Livewire::test('cities.create') + ->set('name', 'No Country City') + ->set('country_id', null) + ->set('latitude', 1) + ->set('longitude', 1) + ->call('createCity') + ->assertHasErrors(['country_id' => 'required']); +}); + +it('rejects city creation with out-of-range latitude', function () { + actingAsUser(); + + Livewire::test('cities.create') + ->set('name', 'Bad Lat') + ->set('country_id', $this->country->id) + ->set('latitude', 150) + ->set('longitude', 0) + ->call('createCity') + ->assertHasErrors(['latitude' => 'between']); +}); + +it('rejects city creation with non-existent country', function () { + actingAsUser(); + + Livewire::test('cities.create') + ->set('name', 'No Country') + ->set('country_id', 999999) + ->set('latitude', 0) + ->set('longitude', 0) + ->call('createCity') + ->assertHasErrors(['country_id' => 'exists']); +}); + +it('updates an existing city', function () { + $city = City::factory()->create(['name' => 'Old Name', 'country_id' => $this->country->id]); + actingAsUser(); + + Livewire::test('cities.edit', ['city' => $city]) + ->set('name', 'New Name') + ->set('country_id', $this->country->id) + ->set('latitude', 52.52) + ->set('longitude', 13.405) + ->call('updateCity') + ->assertHasNoErrors(); + + expect($city->refresh()->name)->toBe('New Name'); +}); diff --git a/tests/Feature/Console/CleanupLoginKeysTest.php b/tests/Feature/Console/CleanupLoginKeysTest.php new file mode 100644 index 0000000..2ce0db9 --- /dev/null +++ b/tests/Feature/Console/CleanupLoginKeysTest.php @@ -0,0 +1,28 @@ +create(); + + $old = LoginKey::factory()->create([ + 'user_id' => $user->id, + 'created_at' => now()->subDays(2), + 'updated_at' => now()->subDays(2), + ]); + $recent = LoginKey::factory()->create([ + 'user_id' => $user->id, + 'created_at' => now()->subHours(2), + 'updated_at' => now()->subHours(2), + ]); + + $this->artisan('loginkeys:cleanup')->assertExitCode(0); + + expect(LoginKey::query()->find($old->id))->toBeNull(); + expect(LoginKey::query()->find($recent->id))->not->toBeNull(); +}); + +it('runs cleanly when no login keys exist', function () { + $this->artisan('loginkeys:cleanup')->assertExitCode(0); +}); diff --git a/tests/Feature/Courses/CourseCrudTest.php b/tests/Feature/Courses/CourseCrudTest.php new file mode 100644 index 0000000..a9e342a --- /dev/null +++ b/tests/Feature/Courses/CourseCrudTest.php @@ -0,0 +1,66 @@ +lecturer = Lecturer::factory()->create(); +}); + +it('creates a Course with valid data', function () { + actingAsUser(); + + Livewire::test('courses.create') + ->set('name', 'Bitcoin 101') + ->set('lecturer_id', $this->lecturer->id) + ->call('createCourse') + ->assertHasNoErrors(); + + expect(Course::query()->where('name', 'Bitcoin 101')->exists())->toBeTrue(); +}); + +it('rejects course creation without a name', function () { + actingAsUser(); + + Livewire::test('courses.create') + ->set('lecturer_id', $this->lecturer->id) + ->call('createCourse') + ->assertHasErrors(['name' => 'required']); +}); + +it('rejects course creation without a lecturer', function () { + actingAsUser(); + + Livewire::test('courses.create') + ->set('name', 'Course Without Lecturer') + ->call('createCourse') + ->assertHasErrors(['lecturer_id' => 'required']); +}); + +it('rejects course creation with non-existent lecturer_id', function () { + actingAsUser(); + + Livewire::test('courses.create') + ->set('name', 'Bad Lecturer Course') + ->set('lecturer_id', 999999) + ->call('createCourse') + ->assertHasErrors(['lecturer_id' => 'exists']); +}); + +it('updates an existing course when authenticated', function () { + $course = Course::factory()->create(['name' => 'Old Name', 'lecturer_id' => $this->lecturer->id]); + actingAsUser(); + + Livewire::test('courses.edit', ['course' => $course]) + ->set('name', 'New Name') + ->set('lecturer_id', $this->lecturer->id) + ->call('updateCourse') + ->assertHasNoErrors(); + + expect($course->refresh()->name)->toBe('New Name'); +}); + +it('redirects guests when accessing course-create', function () { + $this->get('/de/course-create')->assertRedirect(route('login')); +}); diff --git a/tests/Feature/Jobs/FetchNostrProfileJobTest.php b/tests/Feature/Jobs/FetchNostrProfileJobTest.php new file mode 100644 index 0000000..6fff782 --- /dev/null +++ b/tests/Feature/Jobs/FetchNostrProfileJobTest.php @@ -0,0 +1,36 @@ +create(['nostr' => 'npub1'.str_repeat('a', 58)]); + + FetchNostrProfileJob::dispatch($user); + + Queue::assertPushed( + FetchNostrProfileJob::class, + fn (FetchNostrProfileJob $job) => $job->user?->id === $user->id, + ); +}); + +it('can be dispatched without a user (batch mode)', function () { + Queue::fake(); + + FetchNostrProfileJob::dispatch(); + + Queue::assertPushed( + FetchNostrProfileJob::class, + fn (FetchNostrProfileJob $job) => $job->user === null, + ); +}); + +it('returns early when the supplied user has no nostr handle', function () { + $user = User::factory()->create(['nostr' => null]); + + (new FetchNostrProfileJob($user))->handle(); + + expect($user->refresh()->name)->not->toBeEmpty(); +}); diff --git a/tests/Feature/Lecturers/LecturerCrudTest.php b/tests/Feature/Lecturers/LecturerCrudTest.php new file mode 100644 index 0000000..c3547c1 --- /dev/null +++ b/tests/Feature/Lecturers/LecturerCrudTest.php @@ -0,0 +1,59 @@ +set('name', 'Satoshi Nakamoto') + ->call('createLecturer') + ->assertHasNoErrors(); + + expect(Lecturer::query()->where('name', 'Satoshi Nakamoto')->exists())->toBeTrue(); +}); + +it('rejects lecturer creation without name', function () { + actingAsUser(); + + Livewire::test('lecturers.create') + ->call('createLecturer') + ->assertHasErrors(['name' => 'required']); +}); + +it('rejects lecturer creation with duplicate name', function () { + Lecturer::factory()->create(['name' => 'Already Exists']); + actingAsUser(); + + Livewire::test('lecturers.create') + ->set('name', 'Already Exists') + ->call('createLecturer') + ->assertHasErrors(['name' => 'unique']); +}); + +it('rejects lecturer creation with invalid website URL', function () { + actingAsUser(); + + Livewire::test('lecturers.create') + ->set('name', 'Bad URL Lecturer') + ->set('website', 'not-a-url') + ->call('createLecturer') + ->assertHasErrors(['website' => 'url']); +}); + +it('updates an existing lecturer', function () { + $lecturer = Lecturer::factory()->create(['name' => 'Old Name']); + actingAsUser(); + + Livewire::test('lecturers.edit', ['lecturer' => $lecturer]) + ->set('name', 'New Name') + ->call('updateLecturer') + ->assertHasNoErrors(); + + expect($lecturer->refresh()->name)->toBe('New Name'); +}); + +it('redirects guests when accessing lecturer-create', function () { + $this->get('/de/lecturer-create')->assertRedirect(route('login')); +}); diff --git a/tests/Feature/Livewire/Actions/LogoutTest.php b/tests/Feature/Livewire/Actions/LogoutTest.php new file mode 100644 index 0000000..dbc8538 --- /dev/null +++ b/tests/Feature/Livewire/Actions/LogoutTest.php @@ -0,0 +1,26 @@ +check())->toBeTrue(); + + $response = (new Logout)(); + + expect($response->getTargetUrl())->toBe(url('/')); + expect(auth()->check())->toBeFalse(); +}); + +it('still produces a redirect when invoked without an authenticated session', function () { + $response = (new Logout)(); + + expect($response->getTargetUrl())->toBe(url('/')); +}); + +it('is registered for the POST /logout route', function () { + actingAsUser(); + + $this->post('/logout')->assertRedirect('/'); +}); diff --git a/tests/Feature/Livewire/AuthMountTest.php b/tests/Feature/Livewire/AuthMountTest.php new file mode 100644 index 0000000..cf15f1a --- /dev/null +++ b/tests/Feature/Livewire/AuthMountTest.php @@ -0,0 +1,31 @@ +assertStatus(200); +}); + +it('mounts the auth.register component', function () { + Livewire::test('auth.register')->assertStatus(200); +}); + +it('mounts the auth.forgot-password component', function () { + Livewire::test('auth.forgot-password')->assertStatus(200); +}); + +it('mounts the auth.reset-password component with a token', function () { + Livewire::withQueryParams(['email' => 'foo@example.com']) + ->test('auth.reset-password', ['token' => 'fake-reset-token']) + ->assertStatus(200); +}); + +it('mounts the auth.confirm-password component', function () { + actingAsUser(); + Livewire::test('auth.confirm-password')->assertStatus(200); +}); + +it('mounts the auth.verify-email component', function () { + actingAsUser(); + Livewire::test('auth.verify-email')->assertStatus(200); +}); diff --git a/tests/Feature/Livewire/BooksForPlebs/BookRentalGuideTest.php b/tests/Feature/Livewire/BooksForPlebs/BookRentalGuideTest.php new file mode 100644 index 0000000..2644a62 --- /dev/null +++ b/tests/Feature/Livewire/BooksForPlebs/BookRentalGuideTest.php @@ -0,0 +1,14 @@ + Livewire::test(BookRentalGuide::class)->assertStatus(200)) + ->toThrow(ViewException::class, 'Route [buecherverleih.download] not defined.'); +})->skip('Component is unreachable: /buecherverleih route is commented out in routes/web.php — view references the missing buecherverleih.download route.'); + +it('confirms the BookRentalGuide component class still exists', function () { + expect(class_exists(BookRentalGuide::class))->toBeTrue(); +}); diff --git a/tests/Feature/Livewire/CourseMountTest.php b/tests/Feature/Livewire/CourseMountTest.php new file mode 100644 index 0000000..eb7cbbc --- /dev/null +++ b/tests/Feature/Livewire/CourseMountTest.php @@ -0,0 +1,55 @@ +create(['code' => 'de']); + $city = City::factory()->create(['country_id' => $country->id]); + $venue = Venue::factory()->create(['city_id' => $city->id]); + $this->course = Course::factory()->create(); + $this->lecturer = Lecturer::factory()->create(); + $this->event = CourseEvent::factory()->create([ + 'course_id' => $this->course->id, + 'venue_id' => $venue->id, + ]); +}); + +it('mounts courses.landingpage with a course', function () { + Livewire::test('courses.landingpage', ['course' => $this->course])->assertStatus(200); +}); + +it('skips courses.landingpage-event because the Volt component file does not exist (route is broken)', function () { + $path = resource_path('views/livewire/courses/landingpage-event.blade.php'); + expect(file_exists($path))->toBeFalse( + 'The route /course/{course}/event/{event} maps to a missing component file at '.$path + ); +}); + +it('mounts courses.create when authenticated', function () { + actingAsUser(); + Livewire::test('courses.create')->assertStatus(200); +}); + +it('mounts courses.edit when authenticated', function () { + actingAsUser(); + Livewire::test('courses.edit', ['course' => $this->course])->assertStatus(200); +}); + +it('mounts courses.create-edit-events for new event', function () { + actingAsUser(); + Livewire::test('courses.create-edit-events', ['course' => $this->course])->assertStatus(200); +}); + +it('mounts courses.create-edit-events for existing event', function () { + actingAsUser(); + Livewire::test('courses.create-edit-events', [ + 'course' => $this->course, + 'event' => $this->event, + ])->assertStatus(200); +}); diff --git a/tests/Feature/Livewire/CrudMountTest.php b/tests/Feature/Livewire/CrudMountTest.php new file mode 100644 index 0000000..233c584 --- /dev/null +++ b/tests/Feature/Livewire/CrudMountTest.php @@ -0,0 +1,68 @@ +create(['code' => 'de']); + $this->city = City::factory()->create(['country_id' => $country->id]); + $this->venue = Venue::factory()->create(['city_id' => $this->city->id]); + $this->lecturer = Lecturer::factory()->create(); + $this->service = SelfHostedService::factory()->create(); +}); + +it('mounts lecturers.create when authenticated', function () { + actingAsUser(); + Livewire::test('lecturers.create')->assertStatus(200); +}); + +it('mounts lecturers.edit when authenticated', function () { + actingAsUser(); + Livewire::test('lecturers.edit', ['lecturer' => $this->lecturer])->assertStatus(200); +}); + +it('mounts cities.create when authenticated', function () { + actingAsUser(); + Livewire::test('cities.create')->assertStatus(200); +}); + +it('mounts cities.edit when authenticated', function () { + actingAsUser(); + Livewire::test('cities.edit', ['city' => $this->city])->assertStatus(200); +}); + +it('mounts venues.create when authenticated', function () { + actingAsUser(); + Livewire::test('venues.create')->assertStatus(200); +}); + +it('mounts venues.edit when authenticated', function () { + actingAsUser(); + Livewire::test('venues.edit', ['venue' => $this->venue])->assertStatus(200); +}); + +it('mounts services.create when authenticated', function () { + actingAsUser(); + Livewire::test('services.create')->assertStatus(200); +}); + +it('mounts services.edit when authenticated as the service creator', function () { + $owner = actingAsUser(); + $service = SelfHostedService::factory()->create(['created_by' => $owner->id]); + + Livewire::test('services.edit', ['service' => $service])->assertStatus(200); +}); + +it('aborts services.edit with 403 when authenticated user is not the creator', function () { + actingAsUser(); + + Livewire::test('services.edit', ['service' => $this->service])->assertStatus(403); +}); + +it('mounts services.landingpage with a service', function () { + Livewire::test('services.landingpage', ['service' => $this->service])->assertStatus(200); +}); diff --git a/tests/Feature/Livewire/Forms/ServiceFormTest.php b/tests/Feature/Livewire/Forms/ServiceFormTest.php new file mode 100644 index 0000000..56e09e1 --- /dev/null +++ b/tests/Feature/Livewire/Forms/ServiceFormTest.php @@ -0,0 +1,149 @@ +assertStatus(200) + ->assertSet('form.name', '') + ->assertSet('form.anonymous', false); +}); + +it('persists a service when valid data is submitted', function () { + $user = actingAsUser(); + + Livewire::test('services.create') + ->set('form.name', 'Mein Mempool') + ->set('form.type', SelfHostedServiceType::Mempool->value) + ->set('form.url_clearnet', 'https://mempool.example.com') + ->call('save') + ->assertHasNoErrors(); + + $service = SelfHostedService::query()->where('name', 'Mein Mempool')->first(); + + expect($service)->not->toBeNull() + ->and($service->type)->toBe(SelfHostedServiceType::Mempool) + ->and($service->created_by)->toBe($user->id); +}); + +it('rejects submission when no URL or IP is provided', function () { + actingAsUser(); + + Livewire::test('services.create') + ->set('form.name', 'No Endpoint') + ->set('form.type', SelfHostedServiceType::Other->value) + ->call('save'); + + expect(SelfHostedService::query()->where('name', 'No Endpoint')->exists())->toBeFalse(); +}); + +it('validates required name and type fields', function () { + actingAsUser(); + + Livewire::test('services.create') + ->call('save') + ->assertHasErrors([ + 'form.name' => 'required', + 'form.type' => 'required', + ]); +}); + +it('rejects invalid clearnet URLs', function () { + actingAsUser(); + + Livewire::test('services.create') + ->set('form.name', 'Bad URL') + ->set('form.type', SelfHostedServiceType::Other->value) + ->set('form.url_clearnet', 'not-a-valid-url') + ->call('save') + ->assertHasErrors(['form.url_clearnet' => 'url']); +}); + +it('rejects invalid IP addresses', function () { + actingAsUser(); + + Livewire::test('services.create') + ->set('form.name', 'Bad IP') + ->set('form.type', SelfHostedServiceType::Other->value) + ->set('form.ip', 'not-an-ip') + ->call('save') + ->assertHasErrors(['form.ip' => 'ip']); +}); + +it('rejects unknown service types — currently the value reaches the enum cast and triggers a ValueError (rules() in:... is not enforced)', function () { + actingAsUser(); + + expect(function () { + Livewire::test('services.create') + ->set('form.name', 'Bogus Type') + ->set('form.type', 'NotARealType') + ->set('form.url_clearnet', 'https://example.com') + ->call('save'); + })->toThrow(ValueError::class); + + expect(SelfHostedService::query()->where('name', 'Bogus Type')->exists())->toBeFalse(); +}); + +it('accepts every SelfHostedServiceType enum value as form.type', function (SelfHostedServiceType $type) { + actingAsUser(); + + Livewire::test('services.create') + ->set('form.name', 'Service '.$type->value) + ->set('form.type', $type->value) + ->set('form.url_clearnet', 'https://example.com') + ->call('save') + ->assertHasNoErrors(); +})->with(SelfHostedServiceType::cases()); + +it('updates an existing service when authenticated as the creator', function () { + $user = actingAsUser(); + $service = SelfHostedService::factory()->create([ + 'created_by' => $user->id, + 'name' => 'Old Name', + ]); + + Livewire::test('services.edit', ['service' => $service]) + ->set('form.name', 'New Name') + ->set('form.type', SelfHostedServiceType::Mempool->value) + ->set('form.url_clearnet', 'https://mempool.example.com') + ->call('save') + ->assertHasNoErrors(); + + expect($service->refresh()->name)->toBe('New Name'); +}); + +it('marks anonymous service correctly via the anonymous flag', function () { + actingAsUser(); + + Livewire::test('services.create') + ->set('form.name', 'Anon Service') + ->set('form.type', SelfHostedServiceType::Other->value) + ->set('form.url_clearnet', 'https://example.com') + ->set('form.anonymous', true) + ->call('save') + ->assertHasNoErrors(); + + $service = SelfHostedService::query()->where('name', 'Anon Service')->first(); + expect($service->anon)->toBeTrue(); +}); + +it('initializes the form properly via setService when editing', function () { + $user = actingAsUser(); + $service = SelfHostedService::factory()->create([ + 'created_by' => $user->id, + 'name' => 'Filled Service', + 'type' => SelfHostedServiceType::Alby, + 'url_clearnet' => 'https://alby.example.com', + 'anon' => true, + ]); + + Livewire::test('services.edit', ['service' => $service]) + ->assertSet('form.name', 'Filled Service') + ->assertSet('form.type', SelfHostedServiceType::Alby->value) + ->assertSet('form.url_clearnet', 'https://alby.example.com') + ->assertSet('form.anonymous', true); +}); diff --git a/tests/Feature/Livewire/Helper/FollowTheRabbitTest.php b/tests/Feature/Livewire/Helper/FollowTheRabbitTest.php new file mode 100644 index 0000000..5bf379b --- /dev/null +++ b/tests/Feature/Livewire/Helper/FollowTheRabbitTest.php @@ -0,0 +1,12 @@ +assertStatus(200); +}); + +it('is referenced by the /kaninchenbau route', function () { + $this->get('/kaninchenbau')->assertSuccessful(); +}); diff --git a/tests/Feature/Livewire/MeetupMountTest.php b/tests/Feature/Livewire/MeetupMountTest.php new file mode 100644 index 0000000..07dc0cc --- /dev/null +++ b/tests/Feature/Livewire/MeetupMountTest.php @@ -0,0 +1,48 @@ +country = Country::factory()->create(['code' => 'de']); + $this->city = City::factory()->create(['country_id' => $this->country->id]); + $this->meetup = Meetup::factory()->create(['city_id' => $this->city->id]); + $this->event = MeetupEvent::factory()->create(['meetup_id' => $this->meetup->id]); +}); + +it('mounts meetups.landingpage with a meetup', function () { + Livewire::test('meetups.landingpage', ['meetup' => $this->meetup])->assertStatus(200); +}); + +it('mounts meetups.landingpage-event with meetup and event', function () { + Livewire::test('meetups.landingpage-event', [ + 'meetup' => $this->meetup, + 'event' => $this->event, + ])->assertStatus(200); +}); + +it('mounts meetups.create when authenticated', function () { + actingAsUser(); + Livewire::test('meetups.create')->assertStatus(200); +}); + +it('mounts meetups.edit when authenticated', function () { + actingAsUser(); + Livewire::test('meetups.edit', ['meetup' => $this->meetup])->assertStatus(200); +}); + +it('mounts meetups.create-edit-events for new event', function () { + actingAsUser(); + Livewire::test('meetups.create-edit-events', ['meetup' => $this->meetup])->assertStatus(200); +}); + +it('mounts meetups.create-edit-events for existing event', function () { + actingAsUser(); + Livewire::test('meetups.create-edit-events', [ + 'meetup' => $this->meetup, + 'event' => $this->event, + ])->assertStatus(200); +}); diff --git a/tests/Feature/Livewire/SettingsAndUtilityMountTest.php b/tests/Feature/Livewire/SettingsAndUtilityMountTest.php new file mode 100644 index 0000000..ed1c7b9 --- /dev/null +++ b/tests/Feature/Livewire/SettingsAndUtilityMountTest.php @@ -0,0 +1,48 @@ +assertStatus(200); +}); + +it('mounts settings.password when authenticated', function () { + actingAsUser(); + Livewire::test('settings.password')->assertStatus(200); +}); + +it('mounts settings.appearance when authenticated', function () { + actingAsUser(); + Livewire::test('settings.appearance')->assertStatus(200); +}); + +it('mounts settings.delete-user-form when authenticated', function () { + actingAsUser(); + Livewire::test('settings.delete-user-form')->assertStatus(200); +}); + +it('mounts welcome', function () { + Livewire::test('welcome')->assertStatus(200); +}); + +it('mounts language.selector', function () { + Livewire::test('language.selector')->assertStatus(200); +}); + +it('mounts timezone.chooser', function () { + Livewire::test('timezone.chooser')->assertStatus(200); +}); + +it('mounts dashboard.activities', function () { + actingAsUser(); + Livewire::test('dashboard.activities')->assertStatus(200); +}); + +it('mounts dashboard.top-countries', function () { + Livewire::test('dashboard.top-countries')->assertStatus(200); +}); + +it('mounts dashboard.top-meetups', function () { + Livewire::test('dashboard.top-meetups')->assertStatus(200); +}); diff --git a/tests/Feature/Meetups/CreateMeetupTest.php b/tests/Feature/Meetups/CreateMeetupTest.php new file mode 100644 index 0000000..81fe81e --- /dev/null +++ b/tests/Feature/Meetups/CreateMeetupTest.php @@ -0,0 +1,100 @@ +create(['code' => 'de']); + $this->city = City::factory()->create(['country_id' => $country->id]); +}); + +it('creates a Meetup when authenticated user submits a valid form', function () { + actingAsUser(); + + Livewire::test('meetups.create') + ->set('name', 'Berlin Bitcoin Meetup') + ->set('city_id', $this->city->id) + ->set('community', 'einundzwanzig') + ->call('createMeetup') + ->assertHasNoErrors() + ->assertRedirect(); + + $meetup = Meetup::query()->where('name', 'Berlin Bitcoin Meetup')->first(); + expect($meetup)->not->toBeNull() + ->and($meetup->city_id)->toBe($this->city->id); +}); + +it('rejects creation without a name', function () { + actingAsUser(); + + Livewire::test('meetups.create') + ->set('city_id', $this->city->id) + ->set('community', 'einundzwanzig') + ->call('createMeetup') + ->assertHasErrors(['name' => 'required']); +}); + +it('rejects creation without city_id', function () { + actingAsUser(); + + Livewire::test('meetups.create') + ->set('name', 'No City Meetup') + ->set('community', 'einundzwanzig') + ->call('createMeetup') + ->assertHasErrors(['city_id' => 'required']); +}); + +it('rejects creation with non-existent city_id', function () { + actingAsUser(); + + Livewire::test('meetups.create') + ->set('name', 'Bad City Meetup') + ->set('city_id', 999999) + ->set('community', 'einundzwanzig') + ->call('createMeetup') + ->assertHasErrors(['city_id' => 'exists']); +}); + +it('rejects creation with a duplicate meetup name', function () { + Meetup::factory()->create(['name' => 'Already Exists', 'city_id' => $this->city->id]); + actingAsUser(); + + Livewire::test('meetups.create') + ->set('name', 'Already Exists') + ->set('city_id', $this->city->id) + ->set('community', 'einundzwanzig') + ->call('createMeetup') + ->assertHasErrors(['name' => 'unique']); +}); + +it('rejects creation when telegram_link is not a valid URL', function () { + actingAsUser(); + + Livewire::test('meetups.create') + ->set('name', 'Bad URL Meetup') + ->set('city_id', $this->city->id) + ->set('community', 'einundzwanzig') + ->set('telegram_link', 'not-a-url') + ->call('createMeetup') + ->assertHasErrors(['telegram_link' => 'url']); +}); + +it('redirects guests to login when accessing meetup-create', function () { + $this->get('/de/meetup-create')->assertRedirect(route('login')); +}); + +it('creates a city via createCity within the meetup-create flow', function () { + actingAsUser(); + + Livewire::test('meetups.create') + ->set('newCityName', 'Hamburg') + ->set('newCityCountryId', $this->city->country_id) + ->set('newCityLatitude', 53.5511) + ->set('newCityLongitude', 9.9937) + ->call('createCity') + ->assertHasNoErrors(); + + expect(City::query()->where('name', 'Hamburg')->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Meetups/EditMeetupTest.php b/tests/Feature/Meetups/EditMeetupTest.php new file mode 100644 index 0000000..d130daa --- /dev/null +++ b/tests/Feature/Meetups/EditMeetupTest.php @@ -0,0 +1,49 @@ +create(['code' => 'de']); + $this->city = City::factory()->create(['country_id' => $country->id]); + $this->meetup = Meetup::factory()->create(['city_id' => $this->city->id, 'name' => 'Original Name']); +}); + +it('updates an existing Meetup name when authenticated', function () { + actingAsUser(); + + Livewire::test('meetups.edit', ['meetup' => $this->meetup]) + ->set('name', 'Updated Name') + ->set('city_id', $this->city->id) + ->set('community', 'einundzwanzig') + ->call('updateMeetup') + ->assertHasNoErrors(); + + expect($this->meetup->refresh()->name)->toBe('Updated Name'); +}); + +it('rejects update when name collides with another existing Meetup', function () { + Meetup::factory()->create(['name' => 'Other Name', 'city_id' => $this->city->id]); + actingAsUser(); + + Livewire::test('meetups.edit', ['meetup' => $this->meetup]) + ->set('name', 'Other Name') + ->call('updateMeetup') + ->assertHasErrors(['name' => 'unique']); +}); + +it('allows update when name is unchanged (Rule::unique ignores own id)', function () { + actingAsUser(); + + Livewire::test('meetups.edit', ['meetup' => $this->meetup]) + ->set('name', 'Original Name') + ->set('community', 'einundzwanzig') + ->call('updateMeetup') + ->assertHasNoErrors(); +}); + +it('redirects guests when accessing meetup-edit', function () { + $this->get('/de/meetup-edit/'.$this->meetup->id)->assertRedirect(route('login')); +}); diff --git a/tests/Feature/Models/FactoriesTest.php b/tests/Feature/Models/FactoriesTest.php new file mode 100644 index 0000000..3e3797f --- /dev/null +++ b/tests/Feature/Models/FactoriesTest.php @@ -0,0 +1,72 @@ +create(); + + expect($model) + ->toBeInstanceOf($modelClass) + ->and($model->getKey())->not->toBeNull() + ->and($model->exists)->toBeTrue(); + + expect($modelClass::query()->whereKey($model->getKey())->exists())->toBeTrue(); +})->with([ + 'User' => User::class, + 'Country' => Country::class, + 'City' => City::class, + 'Lecturer' => Lecturer::class, + 'Venue' => Venue::class, + 'Category' => Category::class, + 'Course' => Course::class, + 'CourseEvent' => CourseEvent::class, + 'Meetup' => Meetup::class, + 'MeetupEvent' => MeetupEvent::class, + 'BitcoinEvent' => BitcoinEvent::class, + 'Library' => Library::class, + 'LibraryItem' => LibraryItem::class, + 'Episode' => Episode::class, + 'Podcast' => Podcast::class, + 'ProjectProposal' => ProjectProposal::class, + 'Vote' => Vote::class, + 'TwitterAccount' => TwitterAccount::class, + 'SelfHostedService' => SelfHostedService::class, + 'Registration' => Registration::class, + 'Participant' => Participant::class, + 'EmailCampaign' => EmailCampaign::class, + 'EmailTexts' => EmailTexts::class, + 'Highscore' => Highscore::class, + 'LoginKey' => LoginKey::class, + 'Tag' => Tag::class, +]); + +it('skips the App\\Models\\Team factory since Laravel Jetstream is not installed', function (): void { + expect(class_exists('Laravel\\Jetstream\\Team'))->toBeFalse(); +})->skip(class_exists('Laravel\\Jetstream\\Team'), 'Jetstream installed — Team factory should be tested in the main dataset.'); diff --git a/tests/Feature/Notifications/ModelCreatedNotificationTest.php b/tests/Feature/Notifications/ModelCreatedNotificationTest.php new file mode 100644 index 0000000..3d10209 --- /dev/null +++ b/tests/Feature/Notifications/ModelCreatedNotificationTest.php @@ -0,0 +1,26 @@ +create(); + $meetup = Meetup::factory()->create(); + + $user->notify(new ModelCreatedNotification($meetup, 'meetups')); + + Notification::assertSentTo($user, ModelCreatedNotification::class, function ($notification) use ($meetup) { + return $notification->model->is($meetup) && $notification->resource === 'meetups'; + }); +}); + +it('uses the mail channel', function () { + $user = User::factory()->create(); + $notification = new ModelCreatedNotification(User::factory()->make(), 'users'); + + expect($notification->via($user))->toBe(['mail']); +}); diff --git a/tests/Feature/Settings/DeleteUserTest.php b/tests/Feature/Settings/DeleteUserTest.php new file mode 100644 index 0000000..ce6b43c --- /dev/null +++ b/tests/Feature/Settings/DeleteUserTest.php @@ -0,0 +1,29 @@ + Hash::make('correct-password')]); + + Livewire::test('settings.delete-user-form') + ->set('password', 'correct-password') + ->call('deleteUser') + ->assertHasNoErrors() + ->assertRedirect('/'); + + expect(User::query()->find($user->id))->toBeNull(); + expect(auth()->check())->toBeFalse(); +}); + +it('does not delete the user with an incorrect password', function () { + $user = actingAsUser(['password' => Hash::make('correct-password')]); + + Livewire::test('settings.delete-user-form') + ->set('password', 'wrong-password') + ->call('deleteUser') + ->assertHasErrors(['password' => 'current_password']); + + expect(User::query()->find($user->id))->not->toBeNull(); +}); diff --git a/tests/Feature/Settings/PasswordTest.php b/tests/Feature/Settings/PasswordTest.php new file mode 100644 index 0000000..794009c --- /dev/null +++ b/tests/Feature/Settings/PasswordTest.php @@ -0,0 +1,40 @@ + Hash::make('old-password')]); + + Livewire::test('settings.password') + ->set('current_password', 'old-password') + ->set('password', 'new-strong-password!') + ->set('password_confirmation', 'new-strong-password!') + ->call('updatePassword') + ->assertHasNoErrors() + ->assertDispatched('password-updated'); + + expect(Hash::check('new-strong-password!', $user->refresh()->password))->toBeTrue(); +}); + +it('rejects an incorrect current password', function () { + actingAsUser(['password' => Hash::make('correct-password')]); + + Livewire::test('settings.password') + ->set('current_password', 'wrong-password') + ->set('password', 'new-strong-password!') + ->set('password_confirmation', 'new-strong-password!') + ->call('updatePassword') + ->assertHasErrors(['current_password' => 'current_password']); +}); + +it('rejects mismatched password confirmation', function () { + actingAsUser(['password' => Hash::make('correct-password')]); + + Livewire::test('settings.password') + ->set('current_password', 'correct-password') + ->set('password', 'new-strong-password!') + ->set('password_confirmation', 'different-confirmation') + ->call('updatePassword') + ->assertHasErrors(['password' => 'confirmed']); +}); diff --git a/tests/Feature/Settings/ProfileTest.php b/tests/Feature/Settings/ProfileTest.php new file mode 100644 index 0000000..4a170c8 --- /dev/null +++ b/tests/Feature/Settings/ProfileTest.php @@ -0,0 +1,54 @@ + 'old@example.com', 'name' => 'Old Name']); + + Livewire::test('settings.profile') + ->set('name', 'New Name') + ->set('email', 'new@example.com') + ->call('updateProfileInformation') + ->assertHasNoErrors() + ->assertDispatched('profile-updated', name: 'New Name'); + + expect($user->refresh()) + ->name->toBe('New Name') + ->email->toBe('new@example.com') + ->email_verified_at->toBeNull(); +}); + +it('rejects an empty name', function () { + actingAsUser(); + + Livewire::test('settings.profile') + ->set('name', '') + ->call('updateProfileInformation') + ->assertHasErrors(['name' => 'required']); +}); + +it('does NOT enforce the unique-email rule because the email column is CipherSweet-encrypted (Rule::unique scans plain values against the encrypted column and never matches)', function () { + User::factory()->create(['email' => 'taken@example.com']); + actingAsUser(); + + Livewire::test('settings.profile') + ->set('email', 'taken@example.com') + ->call('updateProfileInformation') + ->assertHasNoErrors(); +}); + +it('keeps email_verified_at when email is unchanged', function () { + $user = actingAsUser([ + 'email' => 'same@example.com', + 'email_verified_at' => now(), + ]); + + Livewire::test('settings.profile') + ->set('name', 'Different Name') + ->set('email', 'same@example.com') + ->call('updateProfileInformation') + ->assertHasNoErrors(); + + expect($user->refresh()->email_verified_at)->not->toBeNull(); +}); diff --git a/tests/Feature/Smoke/ApiRoutesTest.php b/tests/Feature/Smoke/ApiRoutesTest.php new file mode 100644 index 0000000..60d28f8 --- /dev/null +++ b/tests/Feature/Smoke/ApiRoutesTest.php @@ -0,0 +1,56 @@ +create(['code' => 'de']); + $city = City::factory()->create(['country_id' => $country->id]); + Venue::factory()->create(['city_id' => $city->id]); + Meetup::factory()->create(['city_id' => $city->id, 'community' => 'einundzwanzig', 'visible_on_map' => true]); + MeetupEvent::factory()->create(); + Course::factory()->create(); + CourseEvent::factory()->create(); + Lecturer::factory()->create(); + Highscore::factory()->create(); + LibraryItem::factory()->create(['type' => 'bindle']); + User::factory()->create(['nostr' => 'npub1'.str_repeat('a', 58)]); +}); + +it('returns a JSON response for the API GET endpoint', function (string $path) { + $this->getJson($path)->assertSuccessful(); +})->with([ + 'countries' => '/api/countries', + 'meetups' => '/api/meetups', + 'meetup events' => '/api/meetup-events', + 'btc-map communities' => '/api/btc-map-communities', + 'nostrplebs' => '/api/nostrplebs', + 'bindles' => '/api/bindles', + 'lecturers' => '/api/lecturers', + 'courses' => '/api/courses', + 'cities' => '/api/cities', + 'venues' => '/api/venues', + 'highscores' => '/api/highscores', +]); + +it('returns 404 for /api/meetup/ical (currently a stub that aborts)', function () { + $this->get('/api/meetup/ical')->assertNotFound(); +}); + +it('returns 404 for /api/meetup index without user_id (currently aborts on missing param)', function () { + $this->getJson('/api/meetup')->assertNotFound(); +}); + +it('returns a successful response for /stream-calendar', function () { + $response = $this->get('/stream-calendar'); + $response->assertSuccessful(); +}); diff --git a/tests/Feature/Smoke/AuthRoutesTest.php b/tests/Feature/Smoke/AuthRoutesTest.php new file mode 100644 index 0000000..64ca538 --- /dev/null +++ b/tests/Feature/Smoke/AuthRoutesTest.php @@ -0,0 +1,48 @@ +create(['code' => 'de']); + $city = City::factory()->create(['country_id' => $country->id]); + Venue::factory()->create(['city_id' => $city->id]); + Meetup::factory()->create(['city_id' => $city->id]); + Lecturer::factory()->create(); + SelfHostedService::factory()->create(); +}); + +it('returns successful response for authenticated routes', function (string $path) { + actingAsUser(); + $this->get($path)->assertSuccessful(); +})->with([ + 'meetup create' => '/de/meetup-create', + 'course create' => '/de/course-create', + 'lecturer create' => '/de/lecturer-create', + 'city create' => '/de/city-create', + 'venue create' => '/de/venue-create', + 'service create' => '/de/service-create', + 'settings profile' => '/de/settings/profile', + 'settings password' => '/de/settings/password', + 'settings appearance' => '/de/settings/appearance', + 'verify email notice' => '/verify-email', + 'confirm password' => '/confirm-password', + 'dashboard' => '/de/dashboard', +]); + +it('redirects to login when guest accesses protected routes', function (string $path) { + $this->get($path)->assertRedirect(route('login')); +})->with([ + 'meetup create' => '/de/meetup-create', + 'service create' => '/de/service-create', + 'settings profile' => '/de/settings/profile', +]); + +it('redirects /de/settings to /settings/profile (current behaviour drops the country prefix)', function () { + actingAsUser(); + $this->get('/de/settings')->assertRedirect('/settings/profile'); +}); diff --git a/tests/Feature/Smoke/PublicRoutesTest.php b/tests/Feature/Smoke/PublicRoutesTest.php new file mode 100644 index 0000000..fd3f2b4 --- /dev/null +++ b/tests/Feature/Smoke/PublicRoutesTest.php @@ -0,0 +1,60 @@ +create(['code' => 'de']); + $city = City::factory()->create(['country_id' => $country->id]); + Venue::factory()->create(['city_id' => $city->id]); + $meetup = Meetup::factory()->create(['city_id' => $city->id]); + MeetupEvent::factory()->create(['meetup_id' => $meetup->id]); + Course::factory()->create(); + Lecturer::factory()->create(); + SelfHostedService::factory()->create(); +}); + +it('returns a successful response for the listed public route', function (string $path) { + $this->get($path)->assertSuccessful(); +})->with([ + 'welcome' => '/welcome', + 'login' => '/login', + 'register' => '/register', + 'forgot password' => '/forgot-password', + 'meetups index' => '/de/meetups', + 'meetups all' => '/de/all-meetups', + 'map' => '/de/map', + 'map world' => '/de/map-world', + 'courses index' => '/de/courses', + 'lecturers index' => '/de/lecturers', + 'cities index' => '/de/cities', + 'venues index' => '/de/venues', + 'services index' => '/de/services', +]); + +it('redirects / to /welcome', function () { + $this->get('/')->assertRedirect('/welcome'); +}); + +it('redirects /de/dashboard to login when guest', function () { + $this->get('/de/dashboard')->assertRedirect(route('login')); +}); + +it('renders /kaninchenbau as a Livewire helper page', function () { + $response = $this->get('/kaninchenbau'); + expect($response->status())->toBeIn([200, 302]); +}); + +it('returns 404 for the application fallback route', function () { + $this->get('/this-route-does-not-exist')->assertNotFound(); +}); + +it('aborts with the requested status code for /error/{code}', function () { + $this->get('/error/418')->assertStatus(418); +}); diff --git a/tests/Feature/Venues/VenueCrudTest.php b/tests/Feature/Venues/VenueCrudTest.php new file mode 100644 index 0000000..077df9d --- /dev/null +++ b/tests/Feature/Venues/VenueCrudTest.php @@ -0,0 +1,62 @@ +create(['code' => 'de']); + $this->city = City::factory()->create(['country_id' => $country->id]); +}); + +it('creates a Venue with valid data', function () { + actingAsUser(); + + Livewire::test('venues.create') + ->set('name', 'Bitcoin Hub Berlin') + ->set('city_id', $this->city->id) + ->set('street', 'Lichtenberger Str. 1') + ->call('createVenue') + ->assertHasNoErrors(); + + expect(Venue::query()->where('name', 'Bitcoin Hub Berlin')->exists())->toBeTrue(); +}); + +it('rejects venue creation without required fields', function () { + actingAsUser(); + + Livewire::test('venues.create') + ->call('createVenue') + ->assertHasErrors([ + 'name' => 'required', + 'city_id' => 'required', + 'street' => 'required', + ]); +}); + +it('rejects venue creation with duplicate name', function () { + Venue::factory()->create(['name' => 'Existing Venue', 'city_id' => $this->city->id]); + actingAsUser(); + + Livewire::test('venues.create') + ->set('name', 'Existing Venue') + ->set('city_id', $this->city->id) + ->set('street', 'Some Street') + ->call('createVenue') + ->assertHasErrors(['name' => 'unique']); +}); + +it('updates an existing venue', function () { + $venue = Venue::factory()->create(['name' => 'Old Name', 'city_id' => $this->city->id]); + actingAsUser(); + + Livewire::test('venues.edit', ['venue' => $venue]) + ->set('name', 'New Name') + ->set('city_id', $this->city->id) + ->set('street', 'New Street 1') + ->call('updateVenue') + ->assertHasNoErrors(); + + expect($venue->refresh()->name)->toBe('New Name'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index a456b57..53c6aab 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,29 +1,36 @@ extend(Tests\TestCase::class) - ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) +pest()->extend(TestCase::class) + ->use(RefreshDatabase::class) + ->beforeEach(function () { + config()->set('permission.testing', true); + app()->make(PermissionRegistrar::class)->forgetCachedPermissions(); + }) ->in('Feature', '../resources/views'); +pest()->extend(TestCase::class) + ->use(RefreshDatabase::class) + ->beforeEach(function () { + config()->set('database.connections.sqlite.database', database_path('testing.sqlite')); + config()->set('permission.testing', true); + }) + ->in('Browser'); + /* |-------------------------------------------------------------------------- | Expectations |-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| */ expect()->extend('toBeOne', function () { @@ -34,14 +41,17 @@ expect()->extend('toBeOne', function () { |-------------------------------------------------------------------------- | Functions |-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| */ -function something() +function actingAsUser(array $attributes = []): User { - // .. + $user = User::factory()->create($attributes); + test()->actingAs($user); + + return $user; +} + +function defaultCountrySegment(): string +{ + return (string) config('app.domain_country', 'de'); } diff --git a/yarn.lock b/yarn.lock index 2f730be..44098a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -858,6 +858,11 @@ fraction.js@^5.3.4: resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -1185,6 +1190,20 @@ picomatch@^4.0.2, picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +playwright-core@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2" + integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg== + +playwright@^1.59: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a" + integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw== + dependencies: + playwright-core "1.59.1" + optionalDependencies: + fsevents "2.3.2" + postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"