8fdfc9ef85
Part (HasMedia): - Spatie media `image` single-file collection + imageUrl() helper - PartResource form: image upload section (image editor, 2 MB max) - Parts list: circular thumbnail column - Shop catalog cards: square thumbnail + 📦 placeholder - Shop part detail: 260px image alongside info, single column when no image Seasonal tire-swap reminders: - NotificationDispatcher::tireSeasonalSwap(TireSet) — Telegram first, email fallback (when set has a vehicle, via ServiceReminderMail with 'tire_swap' type and a size-aware note) - tires:remind-seasonal artisan command, self-gating to Feb 15-Mar 15 (notify winter sets stored) and Sep 15-Oct 15 (notify summer sets stored). 60-day cooldown per client via service_reminders_sent. --force / --dry-run. - Schedule: weekly Mon 09:30 Tests (6 new): - outside window no-ops; spring window notifies winter; spring ignores summer; autumn notifies summer; cooldown blocks doubles; --force overrides window Full suite: 106 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
5.1 KiB
PHP
140 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Central\Company;
|
|
use App\Models\Central\Plan;
|
|
use App\Models\Tenant\Client;
|
|
use App\Models\Tenant\ServiceReminderSent;
|
|
use App\Models\Tenant\TireSet;
|
|
use App\Models\Tenant\TireStorage;
|
|
use App\Tenancy\TenantManager;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Carbon;
|
|
use Tests\TestCase;
|
|
|
|
class TireSeasonalReminderTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_outside_window_does_nothing(): void
|
|
{
|
|
Carbon::setTestNow(Carbon::create(2026, 6, 2));
|
|
$this->makeStoredSet('off', season: 'winter');
|
|
|
|
\Illuminate\Support\Facades\Mail::fake();
|
|
$this->artisan('tires:remind-seasonal')->assertSuccessful();
|
|
|
|
$this->assertEquals(0, ServiceReminderSent::where('type', 'tire_swap')->count());
|
|
Carbon::setTestNow();
|
|
}
|
|
|
|
public function test_spring_window_notifies_winter_stored_sets(): void
|
|
{
|
|
Carbon::setTestNow(Carbon::create(2026, 3, 1));
|
|
$ctx = $this->makeStoredSet('spr', season: 'winter');
|
|
|
|
\Illuminate\Support\Facades\Mail::fake();
|
|
$this->artisan('tires:remind-seasonal')->assertSuccessful();
|
|
|
|
$sent = ServiceReminderSent::where('type', 'tire_swap')
|
|
->where('client_id', $ctx['client']->id)->first();
|
|
$this->assertNotNull($sent);
|
|
Carbon::setTestNow();
|
|
}
|
|
|
|
public function test_spring_window_ignores_summer_sets(): void
|
|
{
|
|
Carbon::setTestNow(Carbon::create(2026, 3, 1));
|
|
$this->makeStoredSet('sum', season: 'summer');
|
|
|
|
\Illuminate\Support\Facades\Mail::fake();
|
|
$this->artisan('tires:remind-seasonal')->assertSuccessful();
|
|
|
|
$this->assertEquals(0, ServiceReminderSent::where('type', 'tire_swap')->count());
|
|
Carbon::setTestNow();
|
|
}
|
|
|
|
public function test_autumn_window_notifies_summer_stored_sets(): void
|
|
{
|
|
Carbon::setTestNow(Carbon::create(2026, 10, 1));
|
|
$ctx = $this->makeStoredSet('aut', season: 'summer');
|
|
|
|
\Illuminate\Support\Facades\Mail::fake();
|
|
$this->artisan('tires:remind-seasonal')->assertSuccessful();
|
|
|
|
$sent = ServiceReminderSent::where('type', 'tire_swap')->first();
|
|
$this->assertNotNull($sent);
|
|
$this->assertEquals($ctx['client']->id, $sent->client_id);
|
|
Carbon::setTestNow();
|
|
}
|
|
|
|
public function test_cooldown_prevents_double_send(): void
|
|
{
|
|
Carbon::setTestNow(Carbon::create(2026, 3, 1));
|
|
$ctx = $this->makeStoredSet('cd', season: 'winter');
|
|
|
|
\Illuminate\Support\Facades\Mail::fake();
|
|
$this->artisan('tires:remind-seasonal')->assertSuccessful();
|
|
$this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count());
|
|
|
|
// Second run same day → cooldown blocks it.
|
|
$this->artisan('tires:remind-seasonal')->assertSuccessful();
|
|
$this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count());
|
|
Carbon::setTestNow();
|
|
}
|
|
|
|
public function test_force_runs_outside_window(): void
|
|
{
|
|
Carbon::setTestNow(Carbon::create(2026, 6, 2));
|
|
$ctx = $this->makeStoredSet('force', season: 'winter');
|
|
|
|
\Illuminate\Support\Facades\Mail::fake();
|
|
$this->artisan('tires:remind-seasonal', ['--force' => true])->assertSuccessful();
|
|
|
|
$this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count());
|
|
Carbon::setTestNow();
|
|
}
|
|
|
|
private function makeStoredSet(string $slug, string $season): array
|
|
{
|
|
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
|
$company = Company::create([
|
|
'plan_id' => $plan->id, 'slug' => $slug,
|
|
'name' => ucfirst($slug), 'status' => 'active',
|
|
'settings' => ['telegram' => ['bot_token' => 'FAKE:TOKEN']],
|
|
]);
|
|
app(TenantManager::class)->setCurrent($company);
|
|
|
|
$client = Client::create([
|
|
'name' => 'TireClient', 'phone' => '+3736' . random_int(1000000, 9999999),
|
|
'email' => $slug . '@example.com',
|
|
'telegram_chat_id' => '9999' . random_int(1000, 9999),
|
|
'type' => 'individual', 'status' => 'active',
|
|
]);
|
|
|
|
$vehicle = \App\Models\Tenant\Vehicle::create([
|
|
'client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5',
|
|
'plate' => 'TS-' . random_int(100, 999),
|
|
]);
|
|
|
|
$set = TireSet::create([
|
|
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
|
'season' => $season, 'brand' => 'Michelin',
|
|
'width' => 205, 'profile' => 55, 'diameter' => 16,
|
|
]);
|
|
TireStorage::create([
|
|
'tire_set_id' => $set->id, 'location' => 'A1-' . $slug,
|
|
'status' => 'stored', 'checked_in_at' => now()->subMonths(2),
|
|
]);
|
|
|
|
// Telegram dispatch goes through HTTP; fake it so the test doesn't
|
|
// hit the real Bot API.
|
|
\Illuminate\Support\Facades\Http::fake([
|
|
'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response(['ok' => true]),
|
|
]);
|
|
|
|
return compact('company', 'client', 'vehicle', 'set');
|
|
}
|
|
}
|