CluPilotCloud/tests/Feature/Auth/DormantAccountTest.php

205 lines
7.7 KiB
PHP

<?php
use App\Console\Commands\PruneDormantAccounts;
use App\Console\Commands\PruneUnverifiedAccounts;
use App\Mail\DormantAccountWarningMail;
use App\Models\Customer;
use App\Models\User;
use App\Models\UserDevice;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Mail;
/**
* A confirmed account that never had a package goes after a year.
*
* The five-day sweep is about registrations nobody confirmed — see
* UnverifiedAccountTest. This is the other half of the same question the owner
* asked: "wird nun der account gelöscht nach 5 tagen?" No. Only an unconfirmed
* one. A confirmed account stays, unless a year passes with no package on it at
* all — and then it is told a fortnight in advance.
*/
function dormantUser(int $daysOld, array $attributes = []): User
{
$user = User::factory()->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');
});