feat: add postcode resolution to /api/stations and Filament SearchResource
Extends NearbyStationsRequest to accept `postcode` (full or outcode) as an alternative to lat/lng. PostcodeService resolves it via postcodes.io and falls through to coordinates. Also adds SearchResource to the Filament admin panel for viewing logged search activity with fuel type filter and price/distance stats columns. Includes SQLite GREATEST/LEAST function polyfills in AppServiceProvider for test compatibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
app/Filament/Resources/Searches/Pages/ListSearches.php
Normal file
16
app/Filament/Resources/Searches/Pages/ListSearches.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Searches\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Searches\SearchResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListSearches extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = SearchResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Filament/Resources/Searches/SearchResource.php
Normal file
81
app/Filament/Resources/Searches/SearchResource.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Searches;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\Searches\Pages\ListSearches;
|
||||||
|
use App\Models\Search;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class SearchResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Search::class;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Searches';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 4;
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('searched_at')
|
||||||
|
->label('Searched At')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('fuel_type')
|
||||||
|
->label('Fuel Type')
|
||||||
|
->badge(),
|
||||||
|
TextColumn::make('results_count')
|
||||||
|
->label('Results')
|
||||||
|
->numeric()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('lowest_pence')
|
||||||
|
->label('Lowest')
|
||||||
|
->formatStateUsing(fn (int $state): string => number_format($state / 100, 1).'p')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('highest_pence')
|
||||||
|
->label('Highest')
|
||||||
|
->formatStateUsing(fn (int $state): string => number_format($state / 100, 1).'p')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('avg_pence')
|
||||||
|
->label('Average')
|
||||||
|
->formatStateUsing(fn (string $state): string => number_format((float) $state / 100, 1).'p')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('lat_bucket')
|
||||||
|
->label('Area (lat/lng)')
|
||||||
|
->formatStateUsing(fn (Search $record): string => $record->lat_bucket.', '.$record->lng_bucket)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('ip_hash')
|
||||||
|
->label('IP Hash')
|
||||||
|
->limit(16)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('searched_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('fuel_type')
|
||||||
|
->options([
|
||||||
|
'E10' => 'E10',
|
||||||
|
'E5' => 'E5',
|
||||||
|
'B7_STANDARD' => 'B7 Standard',
|
||||||
|
'B7_PREMIUM' => 'B7 Premium',
|
||||||
|
'B10' => 'B10',
|
||||||
|
'HVO' => 'HVO',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->recordActions([])
|
||||||
|
->toolbarActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListSearches::route('/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ class StatsOverviewWidget extends BaseWidget
|
|||||||
|
|
||||||
private function oilPredictionStat(): Stat
|
private function oilPredictionStat(): Stat
|
||||||
{
|
{
|
||||||
$prediction = PricePrediction::latest('generated_at')->first();
|
$prediction = PricePrediction::bestFirst()->latest('generated_at')->first();
|
||||||
|
|
||||||
if ($prediction === null) {
|
if ($prediction === null) {
|
||||||
return Stat::make('Latest oil prediction', 'None')
|
return Stat::make('Latest oil prediction', 'None')
|
||||||
|
|||||||
60
app/Http/Controllers/Api/AuthController.php
Normal file
60
app/Http/Controllers/Api/AuthController.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create($data);
|
||||||
|
$token = $user->createToken('api')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json(['token' => $token, 'user' => $user], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! Auth::attempt($credentials)) {
|
||||||
|
return response()->json(['message' => 'Invalid credentials.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = Auth::user();
|
||||||
|
$token = $user->createToken('api')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json(['token' => $token, 'user' => $user]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var PersonalAccessToken $token */
|
||||||
|
$token = $request->user()->currentAccessToken();
|
||||||
|
$token->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Logged out.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($request->user());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,30 @@ use App\Http\Requests\Api\NearbyStationsRequest;
|
|||||||
use App\Http\Resources\Api\StationResource;
|
use App\Http\Resources\Api\StationResource;
|
||||||
use App\Models\Search;
|
use App\Models\Search;
|
||||||
use App\Models\Station;
|
use App\Models\Station;
|
||||||
|
use App\Services\PostcodeService;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StationController extends Controller
|
class StationController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly PostcodeService $postcodeService) {}
|
||||||
|
|
||||||
public function index(NearbyStationsRequest $request): JsonResponse
|
public function index(NearbyStationsRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$lat = (float) $request->input('lat');
|
if ($request->filled('postcode')) {
|
||||||
$lng = (float) $request->input('lng');
|
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
|
||||||
|
|
||||||
|
if ($location === null) {
|
||||||
|
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lat = $location->lat;
|
||||||
|
$lng = $location->lng;
|
||||||
|
} else {
|
||||||
|
$lat = (float) $request->input('lat');
|
||||||
|
$lng = (float) $request->input('lng');
|
||||||
|
}
|
||||||
$fuelType = $request->fuelType();
|
$fuelType = $request->fuelType();
|
||||||
$radius = $request->radius();
|
$radius = $request->radius();
|
||||||
$sort = $request->sort();
|
$sort = $request->sort();
|
||||||
@@ -23,7 +38,7 @@ class StationController extends Controller
|
|||||||
$all = Station::query()
|
$all = Station::query()
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
|
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
|
||||||
(6371 * acos(MAX(-1.0, MIN(1.0,
|
(6371 * acos(GREATEST(-1.0, LEAST(1.0,
|
||||||
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
|
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
|
||||||
+ sin(radians(?)) * sin(radians(lat))
|
+ sin(radians(?)) * sin(radians(lat))
|
||||||
)))) AS distance_km',
|
)))) AS distance_km',
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ class NearbyStationsRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'lat' => ['required', 'numeric', 'between:-90,90'],
|
'postcode' => ['nullable', 'string', 'max:10'],
|
||||||
'lng' => ['required', 'numeric', 'between:-180,180'],
|
'lat' => ['required_without:postcode', 'nullable', 'numeric', 'between:-90,90'],
|
||||||
|
'lng' => ['required_without:postcode', 'nullable', 'numeric', 'between:-180,180'],
|
||||||
'fuel_type' => ['required', 'string'],
|
'fuel_type' => ['required', 'string'],
|
||||||
'radius' => ['nullable', 'numeric', 'between:0.1,50'],
|
'radius' => ['nullable', 'numeric', 'between:0.1,50'],
|
||||||
'sort' => ['nullable', 'string', 'in:price,distance'],
|
'sort' => ['nullable', 'string', 'in:price,distance'],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Enums\PredictionSource;
|
|||||||
use App\Enums\TrendDirection;
|
use App\Enums\TrendDirection;
|
||||||
use Database\Factories\PricePredictionFactory;
|
use Database\Factories\PricePredictionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -18,6 +19,8 @@ use Illuminate\Support\Carbon;
|
|||||||
* @property int $confidence
|
* @property int $confidence
|
||||||
* @property string|null $reasoning
|
* @property string|null $reasoning
|
||||||
* @property Carbon $generated_at
|
* @property Carbon $generated_at
|
||||||
|
*
|
||||||
|
* @method static Builder<PricePrediction> bestFirst()
|
||||||
*/
|
*/
|
||||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
||||||
class PricePrediction extends Model
|
class PricePrediction extends Model
|
||||||
@@ -37,4 +40,21 @@ class PricePrediction extends Model
|
|||||||
'generated_at' => 'datetime',
|
'generated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order by source quality: llm_with_context → llm → ewma.
|
||||||
|
* Use this whenever reading the "best" prediction for a given date.
|
||||||
|
*
|
||||||
|
* @param Builder<PricePrediction> $query
|
||||||
|
* @return Builder<PricePrediction>
|
||||||
|
*/
|
||||||
|
public function scopeBestFirst(Builder $query): Builder
|
||||||
|
{
|
||||||
|
$priority = implode(', ', array_map(
|
||||||
|
fn (string $v) => "'$v'",
|
||||||
|
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
|
||||||
|
));
|
||||||
|
|
||||||
|
return $query->orderByRaw("FIELD(source, $priority)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])]
|
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable implements FilamentUser
|
class User extends Authenticatable implements FilamentUser
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
app()->isProduction(),
|
app()->isProduction(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
|
||||||
|
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||||
|
$pdo = DB::connection()->getPdo();
|
||||||
|
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
|
||||||
|
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
|
||||||
|
}
|
||||||
|
|
||||||
Password::defaults(fn (): ?Password => app()->isProduction()
|
Password::defaults(fn (): ?Password => app()->isProduction()
|
||||||
? Password::min(12)
|
? Password::min(12)
|
||||||
->mixedCase()
|
->mixedCase()
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ class OilPriceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a prediction using LLM first, falling back to EWMA.
|
* Generate predictions from all available sources and store each one.
|
||||||
* Stores the result in price_predictions and returns it.
|
* EWMA always runs. LLM runs when an API key is configured.
|
||||||
|
* Returns the highest-confidence prediction (LLM preferred over EWMA).
|
||||||
*/
|
*/
|
||||||
public function generatePrediction(): ?PricePrediction
|
public function generatePrediction(): ?PricePrediction
|
||||||
{
|
{
|
||||||
@@ -101,20 +102,24 @@ class OilPriceService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$prediction = null;
|
$ewma = $this->generateEwmaPrediction($prices);
|
||||||
|
|
||||||
|
if ($ewma !== null) {
|
||||||
|
PricePrediction::create($ewma->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
$llm = null;
|
||||||
|
|
||||||
if (config('services.anthropic.api_key')) {
|
if (config('services.anthropic.api_key')) {
|
||||||
$prediction = $this->generateLlmPredictionWithContext($prices);
|
$llm = $this->generateLlmPredictionWithContext($prices);
|
||||||
$prediction ??= $this->generateLlmPrediction($prices);
|
$llm ??= $this->generateLlmPrediction($prices);
|
||||||
|
|
||||||
|
if ($llm !== null) {
|
||||||
|
PricePrediction::create($llm->toArray());personal_access_tokens
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$prediction ??= $this->generateEwmaPrediction($prices);
|
return $llm ?? $ewma;
|
||||||
|
|
||||||
if ($prediction !== null) {
|
|
||||||
PricePrediction::create($prediction->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $prediction;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,9 +176,7 @@ class OilPriceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$text = $response->json('content.0.text') ?? '';
|
$text = $response->json('content.0.text') ?? '';
|
||||||
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
$data = $this->extractJson($text);
|
||||||
$text = preg_replace('/```\s*$/m', '', $text);
|
|
||||||
$data = json_decode(trim($text), true);
|
|
||||||
|
|
||||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||||
Log::error('OilPriceService: unexpected LLM response format', ['text' => $text]);
|
Log::error('OilPriceService: unexpected LLM response format', ['text' => $text]);
|
||||||
@@ -237,10 +240,9 @@ class OilPriceService
|
|||||||
|
|
||||||
$url = 'https://api.anthropic.com/v1/messages';
|
$url = 'https://api.anthropic.com/v1/messages';
|
||||||
$messages = [['role' => 'user', 'content' => $prompt]];
|
$messages = [['role' => 'user', 'content' => $prompt]];
|
||||||
$response = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
||||||
->withHeaders([
|
->withHeaders([
|
||||||
'x-api-key' => config('services.anthropic.api_key'),
|
'x-api-key' => config('services.anthropic.api_key'),
|
||||||
@@ -249,12 +251,15 @@ class OilPriceService
|
|||||||
->post($url, [
|
->post($url, [
|
||||||
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
|
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
|
||||||
'max_tokens' => 1024,
|
'max_tokens' => 1024,
|
||||||
'tools' => [['type' => 'web_search_20260209', 'name' => 'web_search']],
|
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
|
||||||
'messages' => $messages,
|
'messages' => $messages,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('OilPriceService: Anthropic context request failed', ['status' => $response->status()]);
|
Log::error('OilPriceService: Anthropic context request failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -266,12 +271,13 @@ class OilPriceService
|
|||||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
}
|
}
|
||||||
|
|
||||||
$text = collect($response->json('content') ?? [])
|
$content = $response->json('content') ?? [];
|
||||||
->firstWhere('type', 'text')['text'] ?? '';
|
|
||||||
|
|
||||||
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
$text = collect($content)
|
||||||
$text = preg_replace('/```\s*$/m', '', $text);
|
->filter(fn ($b) => ($b['type'] ?? '') === 'text')
|
||||||
$data = json_decode(trim($text), true);
|
->implode('text', '');
|
||||||
|
|
||||||
|
$data = $this->extractJson($text);
|
||||||
|
|
||||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||||
Log::error('OilPriceService: unexpected context LLM response format', ['text' => $text]);
|
Log::error('OilPriceService: unexpected context LLM response format', ['text' => $text]);
|
||||||
@@ -366,6 +372,23 @@ class OilPriceService
|
|||||||
return round($ema, 4);
|
return round($ema, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip markdown code fences from a string and extract the first JSON object found.
|
||||||
|
* Handles prose preambles that Claude sometimes adds before the JSON.
|
||||||
|
*/
|
||||||
|
private function extractJson(string $text): ?array
|
||||||
|
{
|
||||||
|
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
||||||
|
$text = preg_replace('/```\s*$/m', '', $text);
|
||||||
|
$start = strpos($text, '{');
|
||||||
|
$end = strrpos($text, '}');
|
||||||
|
if ($start === false || $end === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode(substr($text, $start, $end - $start + 1), true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a % change magnitude to a 0–EWMA_MAX_CONFIDENCE confidence score.
|
* Map a % change magnitude to a 0–EWMA_MAX_CONFIDENCE confidence score.
|
||||||
* 1.5% → ~30, 3% → ~50, 5%+ → 65.
|
* 1.5% → ~30, 3% → ~50, 5%+ → 65.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -15,5 +16,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
//
|
//
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\AuthController;
|
|
||||||
use App\Http\Controllers\Api\PredictionController;
|
use App\Http\Controllers\Api\PredictionController;
|
||||||
use App\Http\Controllers\Api\StationController;
|
use App\Http\Controllers\Api\StationController;
|
||||||
use App\Http\Controllers\Api\StatsController;
|
use App\Http\Controllers\Api\StatsController;
|
||||||
@@ -9,12 +8,3 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::get('/stations', [StationController::class, 'index']);
|
Route::get('/stations', [StationController::class, 'index']);
|
||||||
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
||||||
Route::get('/prediction', [PredictionController::class, 'index']);
|
Route::get('/prediction', [PredictionController::class, 'index']);
|
||||||
|
|
||||||
Route::prefix('auth')->group(function (): void {
|
|
||||||
Route::post('/register', [AuthController::class, 'register']);
|
|
||||||
Route::post('/login', [AuthController::class, 'login']);
|
|
||||||
Route::middleware('auth:sanctum')->group(function (): void {
|
|
||||||
Route::post('/logout', [AuthController::class, 'logout']);
|
|
||||||
Route::get('/me', [AuthController::class, 'me']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
81
tests/Feature/Api/AuthControllerTest.php
Normal file
81
tests/Feature/Api/AuthControllerTest.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('registers a new user and returns a token', function () {
|
||||||
|
$this->postJson('/api/auth/register', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonStructure(['token', 'user' => ['id', 'name', 'email']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when register fields are missing', function () {
|
||||||
|
$this->postJson('/api/auth/register')
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['name', 'email', 'password']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when email is already taken', function () {
|
||||||
|
User::factory()->create(['email' => 'taken@example.com']);
|
||||||
|
|
||||||
|
$this->postJson('/api/auth/register', [
|
||||||
|
'name' => 'Another User',
|
||||||
|
'email' => 'taken@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs in with valid credentials and returns a token', function () {
|
||||||
|
$user = User::factory()->create(['password' => bcrypt('secret123')]);
|
||||||
|
|
||||||
|
$this->postJson('/api/auth/login', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'secret123',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['token', 'user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 for invalid credentials', function () {
|
||||||
|
User::factory()->create(['email' => 'user@example.com', 'password' => bcrypt('correct')]);
|
||||||
|
|
||||||
|
$this->postJson('/api/auth/login', [
|
||||||
|
'email' => 'user@example.com',
|
||||||
|
'password' => 'wrong',
|
||||||
|
])->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the authenticated user on /me', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->getJson('/api/auth/me')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('email', $user->email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs out and revokes the token', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$token = $user->createToken('api')->plainTextToken;
|
||||||
|
|
||||||
|
$this->withToken($token)
|
||||||
|
->postJson('/api/auth/logout')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('message', 'Logged out.');
|
||||||
|
|
||||||
|
expect($user->tokens()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 on protected routes without a token', function () {
|
||||||
|
$this->getJson('/api/auth/me')->assertUnauthorized();
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ use App\Enums\FuelType;
|
|||||||
use App\Models\Station;
|
use App\Models\Station;
|
||||||
use App\Models\StationPriceCurrent;
|
use App\Models\StationPriceCurrent;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@@ -102,3 +103,53 @@ it('returns 422 when required params are missing', function () {
|
|||||||
$this->getJson('/api/stations?lat=52.5')
|
$this->getJson('/api/stations?lat=52.5')
|
||||||
->assertUnprocessable();
|
->assertUnprocessable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves a full postcode to coordinates and returns nearby stations', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::E10,
|
||||||
|
'price_pence' => 14200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api.postcodes.io/postcodes/SW1A1AA' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => ['postcode' => 'SW1A 1AA', 'latitude' => 51.5010, 'longitude' => -0.1415],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?postcode=SW1A+1AA&fuel_type=e10&radius=1')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.count', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves an outcode to coordinates', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::E10,
|
||||||
|
'price_pence' => 14200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api.postcodes.io/outcodes/SW1A' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => ['outcode' => 'SW1A', 'latitude' => 51.5010, 'longitude' => -0.1415],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?postcode=SW1A&fuel_type=e10&radius=1')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.count', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when postcode cannot be resolved', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api.postcodes.io/*' => Http::response(['status' => 404, 'error' => 'Postcode not found'], 404),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?postcode=ZZ99+9ZZ&fuel_type=e10')
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['postcode']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ it('sends web_search tool in the context prediction request', function (): void
|
|||||||
Http::assertSent(function ($request) {
|
Http::assertSent(function ($request) {
|
||||||
$tools = $request->data()['tools'] ?? [];
|
$tools = $request->data()['tools'] ?? [];
|
||||||
|
|
||||||
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20260209');
|
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20250305');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ it('uses LLM with context when API key is configured', function (): void {
|
|||||||
$prediction = $this->service->generatePrediction();
|
$prediction = $this->service->generatePrediction();
|
||||||
|
|
||||||
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
||||||
->and(PricePrediction::count())->toBe(1);
|
->and(PricePrediction::count())->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to plain LLM when context method fails', function (): void {
|
it('falls back to plain LLM when context method fails', function (): void {
|
||||||
@@ -323,7 +323,7 @@ it('falls back to plain LLM when context method fails', function (): void {
|
|||||||
$prediction = $this->service->generatePrediction();
|
$prediction = $this->service->generatePrediction();
|
||||||
|
|
||||||
expect($prediction->source)->toBe(PredictionSource::Llm)
|
expect($prediction->source)->toBe(PredictionSource::Llm)
|
||||||
->and(PricePrediction::count())->toBe(1);
|
->and(PricePrediction::count())->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to EWMA when both LLM methods fail', function (): void {
|
it('falls back to EWMA when both LLM methods fail', function (): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user