Add a prediction box above filter results on the homepage.
Server returns the full payload only when PlanFeatures::can(
'ai_predictions') — currently plus and pro. Other tiers and
guests get a trimmed {fuel_type, predicted_direction,
tier_locked: true} response so the gate is enforced server-side.
Frontend renders a compact one-liner with the national trend
direction for trimmed responses, full card for unlocked.
Hide the Pro plan card from the pricing section (pro plan
disabled in DB pending real Stripe price ids), and only show
the bottom signup CTA when the visitor is a guest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
4.3 KiB
PHP
135 lines
4.3 KiB
PHP
<?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']);
|
|
});
|