Remove prediction API endpoint and integrate into stations search
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

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:
Ovidiu U
2026-04-29 13:28:33 +01:00
parent ee6de23709
commit 088fd11058
29 changed files with 1046 additions and 499 deletions

View File

@@ -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;

View File

@@ -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']);
});

View File

@@ -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');
});

View File

@@ -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
);
});

View File

@@ -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');
});

View File

@@ -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');
});

View File

@@ -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);
});

View File

@@ -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();

View File

@@ -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();
}
});