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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user