🔥 **Cleanup:** Removed BookCase and OrangePill models, factories, migrations, and related references. Added tests for new service and meetup creation flows. Updated PHPUnit settings and browser-specific configurations.

This commit is contained in:
BT
2026-05-02 22:00:26 +01:00
parent 63aed880e1
commit 04e3e30fcf
54 changed files with 3440 additions and 298 deletions
+3
View File
@@ -1,4 +1,7 @@
/.phpunit.cache
/database/testing.sqlite
/tests/Browser/Screenshots
/tests/Browser/Console
/node_modules
/public/build
/public/hot
-7
View File
@@ -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
-7
View File
@@ -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
-7
View File
@@ -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
-86
View File
@@ -1,86 +0,0 @@
<?php
namespace App\Models;
use Akuechler\Geoly;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class BookCase extends Model implements HasMedia
{
use Geoly;
use HasFactory;
use InteractsWithMedia;
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'id' => '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);
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Models;
use App\Gamify\Points\BookCaseOrangePilled;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class OrangePill extends Model implements HasMedia
{
use HasFactory;
use InteractsWithMedia;
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'id' => '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);
}
}
+7 -11
View File
@@ -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);
+1
View File
@@ -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": {
Generated
+1573 -1
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -1,5 +1,9 @@
<?php
use Spatie\Permission\DefaultTeamResolver;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return [
'models' => [
@@ -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),
];
-37
View File
@@ -1,37 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\BookCase;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BookCase>
*/
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(),
];
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\BookCase;
use App\Models\OrangePill;
use App\Models\User;
use Database\Factories\Helpers\NostrHelper;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<OrangePill>
*/
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(),
];
}
}
@@ -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();
});
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::table('media')
->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.'
);
}
};
-11
View File
@@ -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();
Binary file not shown.
+3
View File
@@ -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"
}
}
+9 -1
View File
@@ -11,6 +11,9 @@
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Browser">
<directory>tests/Browser</directory>
</testsuite>
<testsuite name="Components">
<directory suffix=".test.php">resources/views</directory>
</testsuite>
@@ -25,7 +28,9 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="einundzwanzig_app_testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_FOREIGN_KEYS" value="true"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
@@ -33,5 +38,8 @@
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="APP_LOCALE" value="de"/>
<env name="CIPHERSWEET_KEY" value="45186542ebd89a8c7d719543be7531c40a795c40582ceddfca758189d83ce356"/>
<env name="PERMISSION_TESTING" value="true"/>
<env name="PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD" value="1"/>
<env name="PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH" value="/usr/bin/chromium"/>
</php>
</phpunit>
+15
View File
@@ -0,0 +1,15 @@
<?php
it('renders the login page with QR code and language selector', function () {
$page = visit('/login');
$page->assertSee('Login with lightning')
->assertSee('Bitcoin, not blockchain')
->assertNoJavaScriptErrors();
});
it('renders the registration page', function () {
$page = visit('/register');
$page->assertNoJavaScriptErrors();
});
+42
View File
@@ -0,0 +1,42 @@
<?php
use App\Models\City;
use App\Models\Country;
it('lets an authenticated user open the meetup-create page', function () {
actingAsUser();
$country = Country::factory()->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();
});
+34
View File
@@ -0,0 +1,34 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Lecturer;
use App\Models\Meetup;
use App\Models\SelfHostedService;
use App\Models\Venue;
beforeEach(function () {
$country = Country::factory()->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();
});
+71
View File
@@ -0,0 +1,71 @@
<?php
use App\Models\Highscore;
it('returns all highscores ordered by satoshis desc on GET /api/highscores', function () {
Highscore::factory()->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']);
});
+30
View File
@@ -0,0 +1,30 @@
<?php
use App\Models\LibraryItem;
use App\Models\User;
it('returns nostr-pubkeys in /api/nostrplebs', function () {
User::factory()->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');
});
+96
View File
@@ -0,0 +1,96 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use App\Models\MeetupEvent;
beforeEach(function () {
$country = Country::factory()->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();
});
@@ -0,0 +1,43 @@
<?php
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
it('verifies the email when the signed URL is correct', function () {
Event::fake();
$user = actingAsUser(['email_verified_at' => 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();
});
+60
View File
@@ -0,0 +1,60 @@
<?php
use App\Models\LoginKey;
use App\Models\User;
it('returns invalid request parameters when k1 is missing', function () {
$this->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.']);
});
+37
View File
@@ -0,0 +1,37 @@
<?php
use App\Jobs\FetchNostrProfileJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('creates a new user and dispatches FetchNostrProfileJob when an unknown pubkey logs in', function () {
Queue::fake();
$pubkey = 'npub1'.str_repeat('z', 58);
Livewire::test('auth.login')
->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);
});
+82
View File
@@ -0,0 +1,82 @@
<?php
use App\Models\City;
use App\Models\Country;
use Livewire\Livewire;
beforeEach(function () {
$this->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');
});
@@ -0,0 +1,28 @@
<?php
use App\Models\LoginKey;
use App\Models\User;
it('deletes login keys older than 1 day and keeps recent ones', function () {
$user = User::factory()->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);
});
+66
View File
@@ -0,0 +1,66 @@
<?php
use App\Models\Course;
use App\Models\Lecturer;
use Livewire\Livewire;
beforeEach(function () {
$this->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'));
});
@@ -0,0 +1,36 @@
<?php
use App\Jobs\FetchNostrProfileJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
it('can be dispatched for a single user', function () {
Queue::fake();
$user = User::factory()->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();
});
@@ -0,0 +1,59 @@
<?php
use App\Models\Lecturer;
use Livewire\Livewire;
it('creates a Lecturer with valid data', function () {
actingAsUser();
Livewire::test('lecturers.create')
->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'));
});
@@ -0,0 +1,26 @@
<?php
use App\Livewire\Actions\Logout;
it('logs the authenticated user out and redirects to /', function () {
actingAsUser();
expect(auth()->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('/');
});
+31
View File
@@ -0,0 +1,31 @@
<?php
use Livewire\Livewire;
it('mounts the auth.login component', function () {
Livewire::test('auth.login')->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);
});
@@ -0,0 +1,14 @@
<?php
use App\Livewire\BooksForPlebs\BookRentalGuide;
use Illuminate\View\ViewException;
use Livewire\Livewire;
it('mounts the BookRentalGuide component but its view references a route that is currently commented out in routes/web.php', function () {
expect(fn () => 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();
});
@@ -0,0 +1,55 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\Lecturer;
use App\Models\Venue;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->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);
});
+68
View File
@@ -0,0 +1,68 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Lecturer;
use App\Models\SelfHostedService;
use App\Models\Venue;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->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);
});
@@ -0,0 +1,149 @@
<?php
use App\Enums\SelfHostedServiceType;
use App\Models\SelfHostedService;
use Livewire\Livewire;
it('mounts services.create when authenticated', function () {
actingAsUser();
Livewire::test('services.create')
->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);
});
@@ -0,0 +1,12 @@
<?php
use App\Livewire\Helper\FollowTheRabbit;
use Livewire\Livewire;
it('mounts the FollowTheRabbit component', function () {
Livewire::test(FollowTheRabbit::class)->assertStatus(200);
});
it('is referenced by the /kaninchenbau route', function () {
$this->get('/kaninchenbau')->assertSuccessful();
});
@@ -0,0 +1,48 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use Livewire\Livewire;
beforeEach(function () {
$this->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);
});
@@ -0,0 +1,48 @@
<?php
use Livewire\Livewire;
it('mounts settings.profile when authenticated', function () {
actingAsUser();
Livewire::test('settings.profile')->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);
});
+100
View File
@@ -0,0 +1,100 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->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();
});
+49
View File
@@ -0,0 +1,49 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->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'));
});
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Models\BitcoinEvent;
use App\Models\Category;
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\EmailCampaign;
use App\Models\EmailTexts;
use App\Models\Episode;
use App\Models\Highscore;
use App\Models\Lecturer;
use App\Models\Library;
use App\Models\LibraryItem;
use App\Models\LoginKey;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\Participant;
use App\Models\Podcast;
use App\Models\ProjectProposal;
use App\Models\Registration;
use App\Models\SelfHostedService;
use App\Models\Tag;
use App\Models\TwitterAccount;
use App\Models\User;
use App\Models\Venue;
use App\Models\Vote;
use Illuminate\Database\Eloquent\Model;
it('creates a valid persisted record via the factory', function (string $modelClass): void {
/** @var Model $model */
$model = $modelClass::factory()->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.');
@@ -0,0 +1,26 @@
<?php
use App\Models\Meetup;
use App\Models\User;
use App\Notifications\ModelCreatedNotification;
use Illuminate\Support\Facades\Notification;
it('sends a queued mail notification for a created model', function () {
Notification::fake();
$user = User::factory()->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']);
});
+29
View File
@@ -0,0 +1,29 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
it('deletes the user and logs them out when password is correct', function () {
$user = actingAsUser(['password' => 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();
});
+40
View File
@@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
it('updates the password when current password is correct', function () {
$user = actingAsUser(['password' => 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']);
});
+54
View File
@@ -0,0 +1,54 @@
<?php
use App\Models\User;
use Livewire\Livewire;
it('updates the profile name and email when authenticated', function () {
$user = actingAsUser(['email' => '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();
});
+56
View File
@@ -0,0 +1,56 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\Highscore;
use App\Models\Lecturer;
use App\Models\LibraryItem;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\User;
use App\Models\Venue;
beforeEach(function () {
$country = Country::factory()->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();
});
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Lecturer;
use App\Models\Meetup;
use App\Models\SelfHostedService;
use App\Models\Venue;
beforeEach(function () {
$country = Country::factory()->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');
});
+60
View File
@@ -0,0 +1,60 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\Lecturer;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\SelfHostedService;
use App\Models\Venue;
beforeEach(function () {
$country = Country::factory()->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);
});
+62
View File
@@ -0,0 +1,62 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Venue;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->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');
});
+29 -19
View File
@@ -1,29 +1,36 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
pest()->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');
}
+19
View File
@@ -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"