Remove prediction API endpoint and integrate into stations search
Consolidate prediction functionality by merging /api/prediction endpoint into /api/stations response. Move prediction logic from PredictionController into StationController, returning prediction data alongside station results. Replace usePrediction composable with unified useStations that returns {stations, meta, prediction}. Remove PredictionRequest, related tests, and unused Vue components (FuelFinderTest, MapTest, RecommendationTest, StationListTest). Add PredictionFull component and UpsellBanner. Extend NationalFuelPredictionService to include weekly_summary (7-day series, yesterday/today averages, cheapest/priciest days) and oil signal from price_predictions table. Update Home.vue to consume prediction from stations response. Add Plan::resolveCadenceForUser helper and configure Cashier to use custom Subscription model.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -68,6 +69,146 @@ it('returns the authenticated user on /me', function () {
|
||||
->assertJsonPath('email', $user->email);
|
||||
});
|
||||
|
||||
it('reports subscription_cancelled=false for a user with no subscription', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false);
|
||||
});
|
||||
|
||||
it('reports subscription_cancelled=false for an active paid subscription', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_active',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false);
|
||||
});
|
||||
|
||||
it('reports subscription_cancelled=true once the subscription is set to end at period end', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_cancelling',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
'ends_at' => now()->addDays(20),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', true);
|
||||
});
|
||||
|
||||
it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
|
||||
Plan::create([
|
||||
'name' => 'plus',
|
||||
'stripe_price_id_monthly' => 'price_plus_monthly_test',
|
||||
'stripe_price_id_annual' => 'price_plus_annual_test',
|
||||
'features' => ['fuel_types' => ['max' => 1]],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$subscribedAt = now()->subDays(10)->startOfSecond();
|
||||
$renewalAt = now()->addDays(20)->startOfSecond();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_monthly_active',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly_test',
|
||||
'quantity' => 1,
|
||||
'current_period_end' => $renewalAt,
|
||||
'created_at' => $subscribedAt,
|
||||
'updated_at' => $subscribedAt,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false)
|
||||
->assertJsonPath('subscription_cadence', 'monthly');
|
||||
|
||||
expect($response->json('subscribed_at'))->toStartWith($subscribedAt->toDateString());
|
||||
expect($response->json('subscription_expires_at'))->toStartWith($renewalAt->toDateString());
|
||||
});
|
||||
|
||||
it('reports cadence as annual when the active price is the annual one', function () {
|
||||
Plan::create([
|
||||
'name' => 'pro',
|
||||
'stripe_price_id_monthly' => 'price_pro_monthly_test',
|
||||
'stripe_price_id_annual' => 'price_pro_annual_test',
|
||||
'features' => ['fuel_types' => ['max' => null]],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_annual_active',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_pro_annual_test',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cadence', 'annual');
|
||||
});
|
||||
|
||||
it('uses ends_at as the expiry date when subscription is cancelled', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$endsAt = now()->addDays(15)->startOfSecond();
|
||||
$renewalAt = now()->addDays(30)->startOfSecond();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_cancelling_with_period',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
'ends_at' => $endsAt,
|
||||
'current_period_end' => $renewalAt,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', true);
|
||||
|
||||
expect($response->json('subscription_expires_at'))->toStartWith($endsAt->toDateString());
|
||||
});
|
||||
|
||||
it('returns null subscription metadata for users with no subscription', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false)
|
||||
->assertJsonPath('subscription_cadence', null)
|
||||
->assertJsonPath('subscribed_at', null)
|
||||
->assertJsonPath('subscription_expires_at', null);
|
||||
});
|
||||
|
||||
it('logs out and revokes the token', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('api')->plainTextToken;
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
});
|
||||
|
||||
function actAsTier(string $tier): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
if ($tier !== 'free') {
|
||||
UserResource::applyTier($user, $tier, 'monthly');
|
||||
}
|
||||
|
||||
test()->actingAs($user->fresh());
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('returns the full payload for plus users', function () {
|
||||
actAsTier('plus');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['fuel_type', 'reasoning', 'signals'])
|
||||
->assertJsonMissingPath('tier_locked');
|
||||
});
|
||||
|
||||
it('returns the full payload for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
|
||||
'confidence_score', 'confidence_label', 'action', 'reasoning',
|
||||
'prediction_horizon_days', 'region_key', 'methodology',
|
||||
'signals' => [
|
||||
'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
],
|
||||
])
|
||||
->assertJsonPath('fuel_type', 'e10')
|
||||
->assertJsonPath('region_key', 'national');
|
||||
});
|
||||
|
||||
it('returns only direction + tier_locked flag for guests', function () {
|
||||
$response = $this->getJson('/api/prediction')->assertOk();
|
||||
|
||||
expect($response->json())
|
||||
->toHaveKey('fuel_type')
|
||||
->toHaveKey('predicted_direction')
|
||||
->toHaveKey('tier_locked', true)
|
||||
->not->toHaveKey('current_avg')
|
||||
->not->toHaveKey('reasoning')
|
||||
->not->toHaveKey('signals');
|
||||
});
|
||||
|
||||
it('returns the trimmed payload for free users', function () {
|
||||
actAsTier('free');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('tier_locked', true)
|
||||
->assertJsonMissing(['signals' => []])
|
||||
->assertJsonMissingPath('reasoning');
|
||||
});
|
||||
|
||||
it('returns the trimmed payload for basic users', function () {
|
||||
actAsTier('basic');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('tier_locked', true)
|
||||
->assertJsonMissingPath('reasoning');
|
||||
});
|
||||
|
||||
it('includes current average from live prices for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$station = Station::factory()->create();
|
||||
StationPriceCurrent::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14750,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/prediction')->assertOk();
|
||||
|
||||
expect($response->json('current_avg'))->toBe(147.5);
|
||||
});
|
||||
|
||||
it('returns regional prediction when lat and lng are provided to pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$this->getJson('/api/prediction?lat=52.5&lng=-0.2')
|
||||
->assertOk()
|
||||
->assertJsonPath('region_key', 'regional')
|
||||
->assertJsonPath('fuel_type', 'e10');
|
||||
});
|
||||
|
||||
it('returns national prediction without coordinates for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('region_key', 'national');
|
||||
});
|
||||
|
||||
it('returns 422 for invalid lat', function () {
|
||||
$this->getJson('/api/prediction?lat=999&lng=0')
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['lat']);
|
||||
});
|
||||
|
||||
it('returns 422 for invalid lng', function () {
|
||||
$this->getJson('/api/prediction?lat=51.5&lng=999')
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['lng']);
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@@ -12,6 +14,15 @@ beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
});
|
||||
|
||||
function asPaidUserOnStations(string $tier = 'plus'): User
|
||||
{
|
||||
test()->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
$user = User::factory()->create();
|
||||
UserResource::applyTier($user, $tier, 'monthly');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('returns stations near coordinates filtered by fuel type', function () {
|
||||
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||
StationPriceCurrent::factory()->create([
|
||||
@@ -192,3 +203,37 @@ it('includes resolved lat and lng in meta when postcode is provided', function (
|
||||
->assertJsonPath('meta.lat', 51.5010)
|
||||
->assertJsonPath('meta.lng', -0.1415);
|
||||
});
|
||||
|
||||
it('embeds a tier-locked prediction teaser for guest requests', function () {
|
||||
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
|
||||
|
||||
$this->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('prediction.tier_locked', true)
|
||||
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'tier_locked']])
|
||||
->assertJsonMissingPath('prediction.signals')
|
||||
->assertJsonMissingPath('prediction.weekly_summary');
|
||||
});
|
||||
|
||||
it('embeds a tier-locked teaser for free-tier authenticated users', function () {
|
||||
asPaidUserOnStations('free');
|
||||
$user = User::query()->latest('id')->first();
|
||||
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('prediction.tier_locked', true)
|
||||
->assertJsonMissingPath('prediction.signals');
|
||||
});
|
||||
|
||||
it('embeds the full prediction payload for plus users', function () {
|
||||
$user = asPaidUserOnStations('plus');
|
||||
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'confidence_score', 'reasoning', 'weekly_summary', 'signals']])
|
||||
->assertJsonMissingPath('prediction.tier_locked');
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\Fuel\Map;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders the map component', function () {
|
||||
Livewire::test(Map::class)
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('dispatches map-update browser event when stations-found is received', function () {
|
||||
Livewire::test(Map::class)
|
||||
->dispatch('stations-found',
|
||||
results: [['name' => 'BP Garage']],
|
||||
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
|
||||
radius: 5,
|
||||
prediction: null
|
||||
)
|
||||
->assertDispatched('map-update');
|
||||
});
|
||||
|
||||
it('passes radius in map-update payload', function () {
|
||||
Livewire::test(Map::class)
|
||||
->dispatch('stations-found',
|
||||
results: [],
|
||||
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
|
||||
radius: 10,
|
||||
prediction: null
|
||||
)
|
||||
->assertDispatched('map-update', fn ($event, $params) =>
|
||||
$params['radius'] === 10
|
||||
);
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\Fuel\Recommendation;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders nothing before stations-found fires', function () {
|
||||
Livewire::test(Recommendation::class)
|
||||
->assertStatus(200)
|
||||
->assertSet('prediction', null)
|
||||
->assertDontSee('Recommendation');
|
||||
});
|
||||
|
||||
it('shows recommendation card when stations-found includes a prediction', function () {
|
||||
$prediction = [
|
||||
'action' => 'fill_now',
|
||||
'confidence_score' => 80.0,
|
||||
'confidence_label' => 'high',
|
||||
'reasoning' => 'Prices are rising sharply.',
|
||||
'predicted_direction' => 'up',
|
||||
'predicted_change_pence' => 3.5,
|
||||
];
|
||||
|
||||
Livewire::test(Recommendation::class)
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
|
||||
->assertSet('prediction', $prediction)
|
||||
->assertSee('Recommendation')
|
||||
->assertSee('Fill up now');
|
||||
});
|
||||
|
||||
it('shows nothing when stations-found has null prediction', function () {
|
||||
Livewire::test(Recommendation::class)
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
|
||||
->assertSet('prediction', null)
|
||||
->assertDontSee('Recommendation');
|
||||
});
|
||||
|
||||
it('clears previous prediction when new stations-found fires with null prediction', function () {
|
||||
$prediction = [
|
||||
'action' => 'fill_now',
|
||||
'confidence_score' => 80.0,
|
||||
'confidence_label' => 'high',
|
||||
'reasoning' => 'Prices rising.',
|
||||
'predicted_direction' => 'up',
|
||||
'predicted_change_pence' => 3.5,
|
||||
];
|
||||
|
||||
Livewire::test(Recommendation::class)
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
|
||||
->assertSee('Recommendation')
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
|
||||
->assertDontSee('Recommendation');
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\Fuel\StationList;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders empty state before any search', function () {
|
||||
Livewire::test(StationList::class)
|
||||
->assertStatus(200)
|
||||
->assertSet('hasSearched', false)
|
||||
->assertDontSee('Stations Nearby');
|
||||
});
|
||||
|
||||
it('shows station cards after stations-found event', function () {
|
||||
$station = [
|
||||
'station_id' => 'abc123',
|
||||
'name' => 'BP Garage',
|
||||
'brand' => 'BP',
|
||||
'is_supermarket' => false,
|
||||
'address' => '1 High Street',
|
||||
'postcode' => 'SW1A 1AA',
|
||||
'lat' => 51.5074,
|
||||
'lng' => -0.1278,
|
||||
'distance_km' => 1.5,
|
||||
'fuel_type' => 'e10',
|
||||
'price_pence' => 14390,
|
||||
'price' => 143.9,
|
||||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||||
'price_classification' => 'current',
|
||||
'price_classification_label' => 'Current',
|
||||
];
|
||||
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
|
||||
|
||||
Livewire::test(StationList::class)
|
||||
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
|
||||
->assertSet('hasSearched', true)
|
||||
->assertSee('Stations Nearby')
|
||||
->assertSee('BP Garage')
|
||||
->assertSee('1 Result');
|
||||
});
|
||||
|
||||
it('shows empty state message when stations-found has no results', function () {
|
||||
Livewire::test(StationList::class)
|
||||
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
|
||||
->assertSet('hasSearched', true)
|
||||
->assertSee('No stations found');
|
||||
});
|
||||
|
||||
it('updates results when stations-found fires again', function () {
|
||||
$station = [
|
||||
'station_id' => 'abc123',
|
||||
'name' => 'BP Garage',
|
||||
'brand' => 'BP',
|
||||
'is_supermarket' => false,
|
||||
'address' => '1 High Street',
|
||||
'postcode' => 'SW1A 1AA',
|
||||
'lat' => 51.5074,
|
||||
'lng' => -0.1278,
|
||||
'distance_km' => 1.5,
|
||||
'fuel_type' => 'e10',
|
||||
'price_pence' => 14390,
|
||||
'price' => 143.9,
|
||||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||||
'price_classification' => 'current',
|
||||
'price_classification_label' => 'Current',
|
||||
];
|
||||
|
||||
Livewire::test(StationList::class)
|
||||
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
|
||||
->assertSee('BP Garage')
|
||||
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
|
||||
->assertDontSee('BP Garage');
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\FuelFinder;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders the fuel finder shell', function () {
|
||||
Livewire::test(FuelFinder::class)
|
||||
->assertStatus(200);
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -121,6 +122,63 @@ it('on invoice.payment_failed sets grace_period_until 5 days out and queues both
|
||||
Queue::assertPushed(SendPaymentFailedReminderJob::class, 2);
|
||||
});
|
||||
|
||||
it('persists current_period_start, current_period_end and stripe_data on subscription.updated', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_period_1']);
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_period_1',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$start = 1714377600;
|
||||
$end = 1717056000;
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => ['object' => [
|
||||
'id' => 'sub_period_1',
|
||||
'customer' => 'cus_period_1',
|
||||
'current_period_start' => $start,
|
||||
'current_period_end' => $end,
|
||||
'status' => 'active',
|
||||
]],
|
||||
]));
|
||||
|
||||
$sub = Subscription::where('stripe_id', 'sub_period_1')->first();
|
||||
|
||||
expect($sub->current_period_start->timestamp)->toBe($start);
|
||||
expect($sub->current_period_end->timestamp)->toBe($end);
|
||||
expect($sub->stripe_data)->toMatchArray(['id' => 'sub_period_1', 'status' => 'active']);
|
||||
});
|
||||
|
||||
it('reads current_period_end from items.data[0] when not at the root (newer Stripe API)', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_period_2']);
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_period_2',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$end = 1719648000;
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => ['object' => [
|
||||
'id' => 'sub_period_2',
|
||||
'customer' => 'cus_period_2',
|
||||
'items' => ['data' => [['current_period_end' => $end]]],
|
||||
]],
|
||||
]));
|
||||
|
||||
expect(Subscription::where('stripe_id', 'sub_period_2')->value('current_period_end')->timestamp)->toBe($end);
|
||||
});
|
||||
|
||||
it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void {
|
||||
Queue::fake();
|
||||
$existingGrace = now()->addDays(3)->startOfSecond();
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\StationPrice;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -78,14 +79,96 @@ it('includes all required keys in response', function () {
|
||||
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
|
||||
'confidence_score', 'confidence_label', 'action', 'reasoning',
|
||||
'prediction_horizon_days', 'region_key', 'methodology',
|
||||
'signals',
|
||||
'weekly_summary', 'signals',
|
||||
])
|
||||
->and($result['signals'])->toHaveKeys([
|
||||
'trend', 'day_of_week', 'brand_behaviour',
|
||||
'national_momentum', 'regional_momentum', 'price_stickiness',
|
||||
'national_momentum', 'regional_momentum', 'price_stickiness', 'oil',
|
||||
])
|
||||
->and($result['weekly_summary'])->toHaveKeys([
|
||||
'yesterday_avg', 'today_avg', 'tomorrow_estimated_avg',
|
||||
'yesterday_today_delta_pence', 'last_7_days_series',
|
||||
'last_7_days_change_pence', 'cheapest_day', 'priciest_day', 'is_regional',
|
||||
]);
|
||||
});
|
||||
|
||||
it('weekly_summary returns null prices and empty series when there is no data', function () {
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
$weekly = $result['weekly_summary'];
|
||||
|
||||
expect($weekly['yesterday_avg'])->toBeNull()
|
||||
->and($weekly['yesterday_today_delta_pence'])->toBeNull()
|
||||
->and($weekly['last_7_days_series'])->toBe([])
|
||||
->and($weekly['cheapest_day'])->toBeNull()
|
||||
->and($weekly['priciest_day'])->toBeNull()
|
||||
->and($weekly['is_regional'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('weekly_summary populates yesterday avg, today avg and 7-day series from station_prices', function () {
|
||||
$station = Station::factory()->create();
|
||||
StationPriceCurrent::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000,
|
||||
]);
|
||||
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000 + ($daysAgo * 50),
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
$weekly = $result['weekly_summary'];
|
||||
|
||||
expect($weekly['yesterday_avg'])->toBe(140.5)
|
||||
->and($weekly['today_avg'])->toBe(140.0)
|
||||
->and($weekly['yesterday_today_delta_pence'])->toBe(-0.5)
|
||||
->and(count($weekly['last_7_days_series']))->toBe(7)
|
||||
->and($weekly['cheapest_day']['avg'])->toBe(140.0)
|
||||
->and($weekly['priciest_day']['avg'])->toBe(143.0);
|
||||
});
|
||||
|
||||
it('weekly_summary falls back from regional to national when regional data is empty', function () {
|
||||
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
|
||||
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000,
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
// Coordinates 600+ km away from any station — no regional data available.
|
||||
$result = app(NationalFuelPredictionService::class)->predict(58.0, -3.0);
|
||||
$weekly = $result['weekly_summary'];
|
||||
|
||||
expect($weekly['is_regional'])->toBeFalse()
|
||||
->and(count($weekly['last_7_days_series']))->toBe(7);
|
||||
});
|
||||
|
||||
it('weekly_summary marks is_regional true when stations exist within 50km of coordinates', function () {
|
||||
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
|
||||
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000,
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
|
||||
|
||||
expect($result['weekly_summary']['is_regional'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('always returns e10 as fuel_type', function () {
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
@@ -146,3 +229,166 @@ it('disables trend signal when r_squared is below 0.5', function () {
|
||||
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
|
||||
expect($result['signals']['trend']['data_points'])->toBeInt();
|
||||
});
|
||||
|
||||
it('oil signal is disabled when no price_predictions row covers today or later', function () {
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($result['signals']['oil']['enabled'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('oil signal picks up an llm prediction over an ewma one for the same date', function () {
|
||||
DB::table('price_predictions')->insert([
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'ewma',
|
||||
'direction' => 'flat',
|
||||
'confidence' => 60,
|
||||
'reasoning' => null,
|
||||
'generated_at' => now()->subHour(),
|
||||
],
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'rising',
|
||||
'confidence' => 75,
|
||||
'reasoning' => 'OPEC cut',
|
||||
'generated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
|
||||
|
||||
expect($oil['enabled'])->toBeTrue()
|
||||
->and($oil['direction'])->toBe('up')
|
||||
->and($oil['score'])->toBe(1.0)
|
||||
->and($oil['confidence'])->toBe(0.75);
|
||||
});
|
||||
|
||||
it('oil signal prefers llm_with_context over plain llm', function () {
|
||||
DB::table('price_predictions')->insert([
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'falling',
|
||||
'confidence' => 70,
|
||||
'reasoning' => 'baseline',
|
||||
'generated_at' => now(),
|
||||
],
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm_with_context',
|
||||
'direction' => 'rising',
|
||||
'confidence' => 82,
|
||||
'reasoning' => 'with context',
|
||||
'generated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
|
||||
|
||||
expect($oil['direction'])->toBe('up')
|
||||
->and($oil['confidence'])->toBe(0.82);
|
||||
});
|
||||
|
||||
it('confidence reaches "high" when trend and oil agree strongly', function () {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
// Strong falling trend over 7 days, ~1p/day
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('price_predictions')->insert([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'falling',
|
||||
'confidence' => 80,
|
||||
'reasoning' => 'agree',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($result['predicted_direction'])->toBe('down')
|
||||
->and($result['confidence_score'])->toBeGreaterThanOrEqual(70)
|
||||
->and($result['confidence_label'])->toBe('high');
|
||||
});
|
||||
|
||||
it('confidence drops when trend and oil disagree', function () {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
// Strong falling trend
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
// Oil disagrees: rising
|
||||
DB::table('price_predictions')->insert([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'rising',
|
||||
'confidence' => 80,
|
||||
'reasoning' => 'opec',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$agree = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
// Replace oil with one that agrees instead — confidence should be higher
|
||||
DB::table('price_predictions')->update([
|
||||
'direction' => 'falling',
|
||||
]);
|
||||
|
||||
$disagreeReplaced = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($agree['confidence_score'])->toBeLessThan($disagreeReplaced['confidence_score']);
|
||||
});
|
||||
|
||||
it('day-of-week signal activates at 21 days of history (no longer 56)', function () {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
for ($daysAgo = 25; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000 + ($daysAgo % 7) * 50,
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($result['signals']['day_of_week']['enabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('reasoning fallback for the wait action does not say "fill up"', function () {
|
||||
// No data → trend disabled, brand disabled, oil disabled.
|
||||
// Force a "down" direction by injecting an oil prediction that points down with low confidence.
|
||||
DB::table('price_predictions')->insert([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'ewma',
|
||||
'direction' => 'falling',
|
||||
'confidence' => 50,
|
||||
'reasoning' => null,
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
if ($result['action'] === 'wait') {
|
||||
expect($result['reasoning'])->not->toContain('fill up at the cheapest');
|
||||
} else {
|
||||
// If thresholds keep this at no_signal, still verify action-aware fallback exists
|
||||
expect($result['reasoning'])->toBeString();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user