create(array_merge(['email_verified_at' => now()->subDays($daysOld)], $attributes)); $user->forceFill(['created_at' => now()->subDays($daysOld)])->save(); return $user; } it('warns a fortnight before, rather than deleting out of the blue', function () { Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS - PruneDormantAccounts::WARN_DAYS_BEFORE); $this->artisan('clupilot:prune-dormant')->assertSuccessful(); Mail::assertQueued(DormantAccountWarningMail::class, fn ($mail) => $mail->hasTo($user->email)); // Still here. The warning is a warning, not the deletion. expect($user->fresh())->not->toBeNull() ->and($user->fresh()->dormant_warned_at)->not->toBeNull(); }); it('warns once, not every night for a fortnight', function () { Mail::fake(); dormantUser(PruneDormantAccounts::AFTER_DAYS - 5); $this->artisan('clupilot:prune-dormant'); $this->artisan('clupilot:prune-dormant'); $this->artisan('clupilot:prune-dormant'); Mail::assertQueuedCount(1); }); it('deletes the account a year on, once the warning has had its fortnight', function () { Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS + 1); $user->forceFill(['dormant_warned_at' => now()->subDays(PruneDormantAccounts::WARN_DAYS_BEFORE + 1)])->save(); $this->artisan('clupilot:prune-dormant')->assertSuccessful(); expect(User::query()->whereKey($user->id)->exists())->toBeFalse(); }); it('never deletes an account that was never warned', function () { // The stamp is what permits the deletion. An installation whose mail server // was down for a month delays the deletion; it does not skip the notice. Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2); $this->artisan('clupilot:prune-dormant'); expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); }); it('gives the warning its full fortnight before deleting', function () { Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS + 30); $user->forceFill(['dormant_warned_at' => now()->subDays(PruneDormantAccounts::WARN_DAYS_BEFORE - 1)])->save(); $this->artisan('clupilot:prune-dormant'); expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); }); it('leaves an account alone that signed in inside the year', function () { // What the terms promise: signing in once starts the period again. There is // no last_login_at on users, and the device rows carry exactly this. Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 3); UserDevice::query()->create([ 'guard' => 'web', 'authenticatable_id' => $user->id, 'token_hash' => hash('sha256', 'egal'), 'name' => 'Firefox', 'last_seen_at' => now()->subDays(30), ]); $this->artisan('clupilot:prune-dormant'); Mail::assertNothingQueued(); expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); }); it('counts a device of another guard as nobody, because operator 1 is not customer 1', function () { // R21. A device row of the operator guard with the same id says nothing // about this customer, and treating it as activity would keep an abandoned // account alive for as long as an operator keeps signing in. Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS - 1); UserDevice::query()->create([ 'guard' => 'operator', 'authenticatable_id' => $user->id, 'token_hash' => hash('sha256', 'anders'), 'name' => 'Konsole', 'last_seen_at' => now(), ]); $this->artisan('clupilot:prune-dormant'); Mail::assertQueued(DormantAccountWarningMail::class); }); it('never touches an unconfirmed account, which is the other sweep business', function () { Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2, ['email_verified_at' => null]); $this->artisan('clupilot:prune-dormant'); Mail::assertNothingQueued(); expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); }); it('never touches an account with a customer record, by id or by address', function () { // A customer record means somebody in the business knows this person, and // everything commercial — orders, contracts, invoices kept for seven years — // hangs off one. Both links count: see Customer::emailTaken. Mail::fake(); $byId = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2); Customer::factory()->create(['user_id' => $byId->id, 'email' => 'andere@example.test']); $byEmail = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2, ['email' => 'gleich@example.test']); Customer::factory()->create(['email' => 'gleich@example.test', 'user_id' => null]); $this->artisan('clupilot:prune-dormant'); Mail::assertNothingQueued(); expect(User::query()->whereKey($byId->id)->exists())->toBeTrue() ->and(User::query()->whereKey($byEmail->id)->exists())->toBeTrue(); }); it('says what it would do without doing it', function () { Mail::fake(); $user = dormantUser(PruneDormantAccounts::AFTER_DAYS - 1, ['email' => 'ruht@example.test']); $this->artisan('clupilot:prune-dormant --dry-run') ->expectsOutputToContain('ruht@example.test') ->assertSuccessful(); Mail::assertNothingQueued(); expect($user->fresh()->dormant_warned_at)->toBeNull(); }); it('runs on the schedule, or it never runs at all', function () { // Daily even though the window is a year: the notice is what permits the // deletion, so the run that sends it has to happen every day. expect(File::get(base_path('routes/console.php'))) ->toContain("Schedule::command('clupilot:prune-dormant')") ->toContain('withoutOverlapping'); }); // ---- What a customer is told ---- it('states both deadlines in the portal, not just the five days', function () { // The question this answers: "wird nun der account gelöscht nach 5 tagen?" // Reading only the verification page, the answer looked like yes. $settings = File::get(resource_path('views/livewire/settings.blade.php')); expect($settings)->toContain("__('settings.lifecycle_unverified'") ->and($settings)->toContain("__('settings.lifecycle_dormant'") // From the commands themselves, so the page cannot promise one number // while the sweep uses another. ->and($settings)->toContain('PruneUnverifiedAccounts::AFTER_DAYS') ->and($settings)->toContain('PruneDormantAccounts::WARN_DAYS_BEFORE'); }); it('regulates both deadlines in the terms, because that is where it was promised', function () { $terms = $this->get(route('legal.agb'))->assertOk()->getContent(); expect($terms)->toContain('Löschfristen') ->and($terms)->toContain('Nicht bestätigte Registrierungen') ->and($terms)->toContain('Bestätigte Konten ohne Paket') // And the number in the text is the number the command uses. ->and($terms)->toContain(PruneUnverifiedAccounts::AFTER_DAYS.' Tagen'); });