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"