1687 lines
51 KiB
Markdown
1687 lines
51 KiB
Markdown
# Fuel API Ingestion Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Implement the full UK Fuel Finder API ingestion pipeline — OAuth token caching, station metadata upsert, price change detection, historic storage, and scheduled polling.
|
||
|
||
**Architecture:** `FuelPriceService` handles all API communication and DB writes. `StationTaggingService` classifies supermarket stations. Two console commands drive polling (`PollFuelPricesCommand`) and archive rotation (`ArchiveOldPricesCommand`). A `PricesUpdatedEvent` is fired after each poll for downstream use.
|
||
|
||
**Tech Stack:** Laravel 13, PHP 8.4, MySQL (InnoDB + monthly partitions), Pest 4, Laravel HTTP client, Laravel Cache (database driver), Laravel Scheduler.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| File | Action | Purpose |
|
||
|------|--------|---------|
|
||
| `config/services.php` | Modify | Add `fuel_finder` config block |
|
||
| `app/Enums/FuelType.php` | Create | Backed enum for fuel types |
|
||
| `database/migrations/*_create_stations_table.php` | Create | Station metadata table |
|
||
| `database/migrations/*_create_station_prices_current_table.php` | Create | Latest price per station+fuel |
|
||
| `database/migrations/*_create_station_prices_table.php` | Create | Partitioned price history |
|
||
| `database/migrations/*_create_station_prices_archive_table.php` | Create | Archive for prices > 1 year |
|
||
| `app/Models/Station.php` | Create | Eloquent model |
|
||
| `app/Models/StationPriceCurrent.php` | Create | Eloquent model |
|
||
| `app/Models/StationPrice.php` | Create | Eloquent model |
|
||
| `app/Models/StationPriceArchive.php` | Create | Eloquent model |
|
||
| `database/factories/StationFactory.php` | Create | Test factory |
|
||
| `database/factories/StationPriceCurrentFactory.php` | Create | Test factory |
|
||
| `database/factories/StationPriceFactory.php` | Create | Test factory |
|
||
| `app/Services/StationTaggingService.php` | Create | Supermarket brand detection |
|
||
| `app/Services/FuelPriceService.php` | Create | API client + ingestion logic |
|
||
| `app/Events/PricesUpdatedEvent.php` | Create | Fired after each poll |
|
||
| `app/Console/Commands/PollFuelPricesCommand.php` | Create | 15-min incremental + daily full |
|
||
| `app/Console/Commands/ArchiveOldPricesCommand.php` | Create | Move old rows to archive |
|
||
| `bootstrap/app.php` | Modify | Register scheduler |
|
||
| `tests/Unit/Services/StationTaggingServiceTest.php` | Create | Unit tests |
|
||
| `tests/Unit/Services/FuelPriceServiceTest.php` | Create | Unit tests with Http::fake() |
|
||
| `tests/Feature/Commands/PollFuelPricesCommandTest.php` | Create | Command integration tests |
|
||
| `tests/Feature/Commands/ArchiveOldPricesCommandTest.php` | Create | Archive command tests |
|
||
|
||
---
|
||
|
||
## Task 1: Add fuel_finder config
|
||
|
||
**Files:**
|
||
- Modify: `config/services.php`
|
||
|
||
- [ ] **Step 1: Add config block**
|
||
|
||
In `config/services.php`, add before the closing `]`:
|
||
|
||
```php
|
||
'fuel_finder' => [
|
||
'base_url' => env('FUEL_FINDER_BASE_URL', 'https://www.fuel-finder.service.gov.uk/api/v1'),
|
||
'client_id' => env('FUEL_FINDER_CLIENT_ID'),
|
||
'client_secret' => env('FUEL_FINDER_CLIENT_SECRET'),
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 2: Verify .env already has the keys**
|
||
|
||
Check `.env` contains:
|
||
```
|
||
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
|
||
FUEL_FINDER_CLIENT_ID=2MJ9mYAmZMApMF6fHT2rwd8pSL3JP9D3
|
||
FUEL_FINDER_CLIENT_SECRET=oAZsWBagGD8YTn68BnWDjbV61SA7nOrHHlzKhH3s3oMj23kv0MsI8l25ILUbL5j6
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add config/services.php
|
||
git commit -m "config: add fuel_finder service credentials"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: FuelType enum
|
||
|
||
**Files:**
|
||
- Create: `app/Enums/FuelType.php`
|
||
|
||
- [ ] **Step 1: Create the enum**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Enums;
|
||
|
||
enum FuelType: string
|
||
{
|
||
case E10 = 'e10';
|
||
case E5 = 'e5';
|
||
case B7Standard = 'b7_standard';
|
||
case B7Premium = 'b7_premium';
|
||
case B10 = 'b10';
|
||
case Hvo = 'hvo';
|
||
|
||
public static function fromApiValue(string $value): self
|
||
{
|
||
return self::from(strtolower($value));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add app/Enums/FuelType.php
|
||
git commit -m "feat: add FuelType backed enum"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Migrations
|
||
|
||
**Files:**
|
||
- Create: 4 migration files
|
||
|
||
- [ ] **Step 1: Generate migration stubs**
|
||
|
||
```bash
|
||
php artisan make:migration create_stations_table
|
||
php artisan make:migration create_station_prices_current_table
|
||
php artisan make:migration create_station_prices_table
|
||
php artisan make:migration create_station_prices_archive_table
|
||
```
|
||
|
||
- [ ] **Step 2: Implement stations migration**
|
||
|
||
Replace the generated `up()` method:
|
||
|
||
```php
|
||
public function up(): void
|
||
{
|
||
Schema::create('stations', function (Blueprint $table): void {
|
||
$table->string('node_id', 64)->primary();
|
||
$table->string('trading_name', 128);
|
||
$table->string('brand_name', 64)->nullable();
|
||
$table->boolean('is_same_trading_and_brand')->default(false);
|
||
$table->boolean('is_supermarket')->default(false)->comment('Set by StationTaggingService');
|
||
$table->boolean('is_motorway_service_station')->default(false);
|
||
$table->boolean('is_supermarket_service_station')->default(false);
|
||
$table->boolean('temporary_closure')->default(false);
|
||
$table->boolean('permanent_closure')->default(false);
|
||
$table->date('permanent_closure_date')->nullable();
|
||
$table->string('public_phone_number', 20)->nullable();
|
||
$table->string('address_line_1', 255)->nullable();
|
||
$table->string('address_line_2', 255)->nullable();
|
||
$table->string('city', 100)->nullable();
|
||
$table->string('county', 100)->nullable();
|
||
$table->string('country', 64)->nullable();
|
||
$table->string('postcode', 10);
|
||
$table->decimal('lat', 10, 7);
|
||
$table->decimal('lng', 10, 7);
|
||
$table->json('amenities')->nullable();
|
||
$table->json('opening_times')->nullable();
|
||
$table->json('fuel_types')->nullable();
|
||
$table->dateTime('last_seen_at');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement station_prices_current migration**
|
||
|
||
```php
|
||
public function up(): void
|
||
{
|
||
Schema::create('station_prices_current', function (Blueprint $table): void {
|
||
$table->string('station_id', 64);
|
||
$table->string('fuel_type', 20);
|
||
$table->unsignedSmallInteger('price_pence')->comment('Price in pence × 100, e.g. 15990 = 159.90p');
|
||
$table->dateTime('price_effective_at')->comment('price_change_effective_timestamp from API');
|
||
$table->dateTime('price_reported_at')->comment('price_last_updated from API');
|
||
$table->dateTime('recorded_at')->comment('When this row was last upserted by us');
|
||
|
||
$table->primary(['station_id', 'fuel_type']);
|
||
$table->foreign('station_id')->references('node_id')->on('stations')->cascadeOnDelete();
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement station_prices migration (partitioned)**
|
||
|
||
```php
|
||
public function up(): void
|
||
{
|
||
Schema::create('station_prices', function (Blueprint $table): void {
|
||
$table->bigIncrements('id');
|
||
$table->string('station_id', 64);
|
||
$table->string('fuel_type', 20);
|
||
$table->unsignedSmallInteger('price_pence')->comment('Price in pence × 100');
|
||
$table->dateTime('price_effective_at');
|
||
$table->dateTime('price_reported_at');
|
||
$table->dateTime('recorded_at');
|
||
|
||
// Composite PK required for MySQL range partitioning
|
||
$table->primary(['id', 'price_effective_at']);
|
||
$table->index(['station_id', 'fuel_type', 'price_effective_at']);
|
||
$table->index('price_effective_at');
|
||
});
|
||
|
||
// Add monthly partitions for 2026-2027, with MAXVALUE catch-all
|
||
DB::statement("ALTER TABLE station_prices
|
||
PARTITION BY RANGE (YEAR(price_effective_at) * 100 + MONTH(price_effective_at)) (
|
||
PARTITION p202601 VALUES LESS THAN (202602),
|
||
PARTITION p202602 VALUES LESS THAN (202603),
|
||
PARTITION p202603 VALUES LESS THAN (202604),
|
||
PARTITION p202604 VALUES LESS THAN (202605),
|
||
PARTITION p202605 VALUES LESS THAN (202606),
|
||
PARTITION p202606 VALUES LESS THAN (202607),
|
||
PARTITION p202607 VALUES LESS THAN (202608),
|
||
PARTITION p202608 VALUES LESS THAN (202609),
|
||
PARTITION p202609 VALUES LESS THAN (202610),
|
||
PARTITION p202610 VALUES LESS THAN (202611),
|
||
PARTITION p202611 VALUES LESS THAN (202612),
|
||
PARTITION p202612 VALUES LESS THAN (202701),
|
||
PARTITION p202701 VALUES LESS THAN (202702),
|
||
PARTITION p202702 VALUES LESS THAN (202703),
|
||
PARTITION p202703 VALUES LESS THAN (202704),
|
||
PARTITION p202704 VALUES LESS THAN (202705),
|
||
PARTITION p202705 VALUES LESS THAN (202706),
|
||
PARTITION p202706 VALUES LESS THAN (202707),
|
||
PARTITION p202707 VALUES LESS THAN (202708),
|
||
PARTITION p202708 VALUES LESS THAN (202709),
|
||
PARTITION p202709 VALUES LESS THAN (202710),
|
||
PARTITION p202710 VALUES LESS THAN (202711),
|
||
PARTITION p202711 VALUES LESS THAN (202712),
|
||
PARTITION p202712 VALUES LESS THAN (202801),
|
||
PARTITION pFuture VALUES LESS THAN MAXVALUE
|
||
)");
|
||
}
|
||
|
||
public function down(): void
|
||
{
|
||
Schema::dropIfExists('station_prices');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement station_prices_archive migration**
|
||
|
||
```php
|
||
public function up(): void
|
||
{
|
||
Schema::create('station_prices_archive', function (Blueprint $table): void {
|
||
$table->bigIncrements('id');
|
||
$table->string('station_id', 64);
|
||
$table->string('fuel_type', 20);
|
||
$table->unsignedSmallInteger('price_pence');
|
||
$table->dateTime('price_effective_at');
|
||
$table->dateTime('price_reported_at');
|
||
$table->dateTime('recorded_at');
|
||
|
||
$table->index(['station_id', 'fuel_type', 'price_effective_at']);
|
||
$table->index('price_effective_at');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run migrations**
|
||
|
||
```bash
|
||
php artisan migrate
|
||
```
|
||
|
||
Expected: 4 new tables created with no errors.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add database/migrations/
|
||
git commit -m "feat: add stations and station prices migrations"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Eloquent models
|
||
|
||
**Files:**
|
||
- Create: `app/Models/Station.php`
|
||
- Create: `app/Models/StationPriceCurrent.php`
|
||
- Create: `app/Models/StationPrice.php`
|
||
- Create: `app/Models/StationPriceArchive.php`
|
||
|
||
- [ ] **Step 1: Create Station model**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\FuelType;
|
||
use Database\Factories\StationFactory;
|
||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
|
||
#[Fillable([
|
||
'node_id', 'trading_name', 'brand_name', 'is_same_trading_and_brand',
|
||
'is_supermarket', 'is_motorway_service_station', 'is_supermarket_service_station',
|
||
'temporary_closure', 'permanent_closure', 'permanent_closure_date',
|
||
'public_phone_number', 'address_line_1', 'address_line_2', 'city',
|
||
'county', 'country', 'postcode', 'lat', 'lng',
|
||
'amenities', 'opening_times', 'fuel_types', 'last_seen_at',
|
||
])]
|
||
class Station extends Model
|
||
{
|
||
/** @use HasFactory<StationFactory> */
|
||
use HasFactory;
|
||
|
||
public $timestamps = false;
|
||
protected $primaryKey = 'node_id';
|
||
public $incrementing = false;
|
||
protected $keyType = 'string';
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'is_same_trading_and_brand' => 'boolean',
|
||
'is_supermarket' => 'boolean',
|
||
'is_motorway_service_station' => 'boolean',
|
||
'is_supermarket_service_station' => 'boolean',
|
||
'temporary_closure' => 'boolean',
|
||
'permanent_closure' => 'boolean',
|
||
'permanent_closure_date' => 'date',
|
||
'amenities' => 'array',
|
||
'opening_times' => 'array',
|
||
'fuel_types' => 'array',
|
||
'last_seen_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
public function currentPrices(): HasMany
|
||
{
|
||
return $this->hasMany(StationPriceCurrent::class, 'station_id', 'node_id');
|
||
}
|
||
|
||
public function prices(): HasMany
|
||
{
|
||
return $this->hasMany(StationPrice::class, 'station_id', 'node_id');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create StationPriceCurrent model**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\FuelType;
|
||
use Database\Factories\StationPriceCurrentFactory;
|
||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||
class StationPriceCurrent extends Model
|
||
{
|
||
/** @use HasFactory<StationPriceCurrentFactory> */
|
||
use HasFactory;
|
||
|
||
public $timestamps = false;
|
||
protected $primaryKey = null;
|
||
public $incrementing = false;
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'fuel_type' => FuelType::class,
|
||
'price_effective_at' => 'datetime',
|
||
'price_reported_at' => 'datetime',
|
||
'recorded_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
public function station(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Station::class, 'station_id', 'node_id');
|
||
}
|
||
|
||
/** Price in pence as a float, e.g. 159.90 */
|
||
public function priceInPence(): float
|
||
{
|
||
return $this->price_pence / 100;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create StationPrice model**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\FuelType;
|
||
use Database\Factories\StationPriceFactory;
|
||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||
class StationPrice extends Model
|
||
{
|
||
/** @use HasFactory<StationPriceFactory> */
|
||
use HasFactory;
|
||
|
||
public $timestamps = false;
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'fuel_type' => FuelType::class,
|
||
'price_effective_at' => 'datetime',
|
||
'price_reported_at' => 'datetime',
|
||
'recorded_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
public function station(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Station::class, 'station_id', 'node_id');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create StationPriceArchive model**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\FuelType;
|
||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||
class StationPriceArchive extends Model
|
||
{
|
||
public $timestamps = false;
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'fuel_type' => FuelType::class,
|
||
'price_effective_at' => 'datetime',
|
||
'price_reported_at' => 'datetime',
|
||
'recorded_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
public function station(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Station::class, 'station_id', 'node_id');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Models/ app/Enums/
|
||
git commit -m "feat: add Station, StationPrice, StationPriceCurrent, StationPriceArchive models"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Factories
|
||
|
||
**Files:**
|
||
- Create: `database/factories/StationFactory.php`
|
||
- Create: `database/factories/StationPriceCurrentFactory.php`
|
||
- Create: `database/factories/StationPriceFactory.php`
|
||
|
||
- [ ] **Step 1: Create StationFactory**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace Database\Factories;
|
||
|
||
use App\Models\Station;
|
||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||
|
||
/** @extends Factory<Station> */
|
||
class StationFactory extends Factory
|
||
{
|
||
public function definition(): array
|
||
{
|
||
$trading = $this->faker->company();
|
||
|
||
return [
|
||
'node_id' => hash('sha256', $this->faker->unique()->uuid()),
|
||
'trading_name' => $trading,
|
||
'brand_name' => $trading,
|
||
'is_same_trading_and_brand' => true,
|
||
'is_supermarket' => false,
|
||
'is_motorway_service_station' => false,
|
||
'is_supermarket_service_station' => false,
|
||
'temporary_closure' => false,
|
||
'permanent_closure' => false,
|
||
'permanent_closure_date' => null,
|
||
'public_phone_number' => null,
|
||
'address_line_1' => $this->faker->streetAddress(),
|
||
'address_line_2' => null,
|
||
'city' => $this->faker->city(),
|
||
'county' => null,
|
||
'country' => 'England',
|
||
'postcode' => strtoupper($this->faker->postcode()),
|
||
'lat' => $this->faker->latitude(49.9, 60.9),
|
||
'lng' => $this->faker->longitude(-8.2, 1.8),
|
||
'amenities' => [],
|
||
'opening_times' => null,
|
||
'fuel_types' => ['E10', 'E5'],
|
||
'last_seen_at' => now(),
|
||
];
|
||
}
|
||
|
||
public function supermarket(): static
|
||
{
|
||
return $this->state([
|
||
'trading_name' => 'Tesco',
|
||
'brand_name' => 'Tesco',
|
||
'is_supermarket'=> true,
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create StationPriceCurrentFactory**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace Database\Factories;
|
||
|
||
use App\Enums\FuelType;
|
||
use App\Models\Station;
|
||
use App\Models\StationPriceCurrent;
|
||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||
|
||
/** @extends Factory<StationPriceCurrent> */
|
||
class StationPriceCurrentFactory extends Factory
|
||
{
|
||
public function definition(): array
|
||
{
|
||
return [
|
||
'station_id' => Station::factory(),
|
||
'fuel_type' => FuelType::E10,
|
||
'price_pence' => $this->faker->numberBetween(12000, 18000),
|
||
'price_effective_at' => now()->subHour(),
|
||
'price_reported_at' => now()->subMinutes(30),
|
||
'recorded_at' => now(),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create StationPriceFactory**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace Database\Factories;
|
||
|
||
use App\Enums\FuelType;
|
||
use App\Models\Station;
|
||
use App\Models\StationPrice;
|
||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||
|
||
/** @extends Factory<StationPrice> */
|
||
class StationPriceFactory extends Factory
|
||
{
|
||
public function definition(): array
|
||
{
|
||
return [
|
||
'station_id' => Station::factory(),
|
||
'fuel_type' => FuelType::E10,
|
||
'price_pence' => $this->faker->numberBetween(12000, 18000),
|
||
'price_effective_at' => now()->subDays($this->faker->numberBetween(1, 30)),
|
||
'price_reported_at' => now()->subDays($this->faker->numberBetween(1, 30)),
|
||
'recorded_at' => now(),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add database/factories/
|
||
git commit -m "feat: add Station, StationPrice, StationPriceCurrent factories"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: StationTaggingService
|
||
|
||
**Files:**
|
||
- Create: `app/Services/StationTaggingService.php`
|
||
- Create: `tests/Unit/Services/StationTaggingServiceTest.php`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Models\Station;
|
||
use App\Services\StationTaggingService;
|
||
|
||
beforeEach(function (): void {
|
||
$this->service = new StationTaggingService();
|
||
});
|
||
|
||
it('marks tesco station as supermarket and normalises brand', function (): void {
|
||
$station = Station::factory()->make(['trading_name' => 'TESCO EXTRA', 'is_supermarket' => false]);
|
||
|
||
$this->service->tag($station);
|
||
|
||
expect($station->is_supermarket)->toBeTrue()
|
||
->and($station->brand_name)->toBe('Tesco');
|
||
});
|
||
|
||
it('marks asda station as supermarket', function (): void {
|
||
$station = Station::factory()->make(['trading_name' => 'Asda Petrol Station', 'is_supermarket' => false]);
|
||
|
||
$this->service->tag($station);
|
||
|
||
expect($station->is_supermarket)->toBeTrue()
|
||
->and($station->brand_name)->toBe('Asda');
|
||
});
|
||
|
||
it('does not mark independent station as supermarket', function (): void {
|
||
$station = Station::factory()->make(['trading_name' => 'Village Garage', 'is_supermarket' => false]);
|
||
|
||
$this->service->tag($station);
|
||
|
||
expect($station->is_supermarket)->toBeFalse();
|
||
});
|
||
|
||
it('handles case insensitive matching', function (): void {
|
||
$station = Station::factory()->make(['trading_name' => 'morrisons petrol', 'is_supermarket' => false]);
|
||
|
||
$this->service->tag($station);
|
||
|
||
expect($station->is_supermarket)->toBeTrue()
|
||
->and($station->brand_name)->toBe('Morrisons');
|
||
});
|
||
|
||
it('does not overwrite brand_name for non-supermarket stations', function (): void {
|
||
$station = Station::factory()->make([
|
||
'trading_name' => 'Shell Garage',
|
||
'brand_name' => 'Shell',
|
||
'is_supermarket' => false,
|
||
]);
|
||
|
||
$this->service->tag($station);
|
||
|
||
expect($station->is_supermarket)->toBeFalse()
|
||
->and($station->brand_name)->toBe('Shell');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/StationTaggingServiceTest.php
|
||
```
|
||
|
||
Expected: FAIL — `StationTaggingService` not found.
|
||
|
||
- [ ] **Step 3: Implement StationTaggingService**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Station;
|
||
|
||
class StationTaggingService
|
||
{
|
||
/** @var array<string, string> brand keyword → normalised brand name */
|
||
private const SUPERMARKET_BRANDS = [
|
||
'tesco' => 'Tesco',
|
||
'asda' => 'Asda',
|
||
'morrisons' => 'Morrisons',
|
||
'sainsbury' => 'Sainsbury\'s',
|
||
'aldi' => 'Aldi',
|
||
'lidl' => 'Lidl',
|
||
'costco' => 'Costco',
|
||
];
|
||
|
||
public function tag(Station $station): void
|
||
{
|
||
$name = strtolower($station->trading_name);
|
||
|
||
foreach (self::SUPERMARKET_BRANDS as $keyword => $normalisedBrand) {
|
||
if (str_contains($name, $keyword)) {
|
||
$station->is_supermarket = true;
|
||
$station->brand_name = $normalisedBrand;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/StationTaggingServiceTest.php
|
||
```
|
||
|
||
Expected: 5 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Services/StationTaggingService.php tests/Unit/Services/StationTaggingServiceTest.php
|
||
git commit -m "feat: add StationTaggingService with supermarket detection"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: FuelPriceService — OAuth token
|
||
|
||
**Files:**
|
||
- Create: `app/Services/FuelPriceService.php`
|
||
- Create: `tests/Unit/Services/FuelPriceServiceTest.php`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Services\FuelPriceService;
|
||
use App\Services\StationTaggingService;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
|
||
beforeEach(function (): void {
|
||
$this->service = new FuelPriceService(new StationTaggingService());
|
||
});
|
||
|
||
it('fetches and caches an access token', function (): void {
|
||
Http::fake([
|
||
'*/oauth/generate_access_token' => Http::response([
|
||
'data' => [
|
||
'access_token' => 'test-token-abc',
|
||
'expires_in' => 3600,
|
||
],
|
||
]),
|
||
]);
|
||
|
||
$token = $this->service->getAccessToken();
|
||
|
||
expect($token)->toBe('test-token-abc');
|
||
expect(Cache::get('fuel_finder_access_token'))->toBe('test-token-abc');
|
||
});
|
||
|
||
it('returns cached token without hitting API', function (): void {
|
||
Cache::put('fuel_finder_access_token', 'cached-token', 3540);
|
||
|
||
Http::fake(); // no calls expected
|
||
|
||
$token = $this->service->getAccessToken();
|
||
|
||
expect($token)->toBe('cached-token');
|
||
Http::assertNothingSent();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
|
||
```
|
||
|
||
Expected: FAIL — `FuelPriceService` not found.
|
||
|
||
- [ ] **Step 3: Implement FuelPriceService with token method**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
|
||
class FuelPriceService
|
||
{
|
||
private const TOKEN_CACHE_KEY = 'fuel_finder_access_token';
|
||
|
||
public function __construct(
|
||
private readonly StationTaggingService $taggingService,
|
||
) {}
|
||
|
||
public function getAccessToken(): string
|
||
{
|
||
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
||
$response = Http::timeout(10)
|
||
->post(config('services.fuel_finder.base_url').'/oauth/generate_access_token', [
|
||
'client_id' => config('services.fuel_finder.client_id'),
|
||
'client_secret' => config('services.fuel_finder.client_secret'),
|
||
]);
|
||
|
||
return $response->json('data.access_token');
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
|
||
```
|
||
|
||
Expected: 2 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php
|
||
git commit -m "feat: FuelPriceService with OAuth token caching"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: FuelPriceService — station upsert
|
||
|
||
**Files:**
|
||
- Modify: `app/Services/FuelPriceService.php`
|
||
- Modify: `tests/Unit/Services/FuelPriceServiceTest.php`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Add to `tests/Unit/Services/FuelPriceServiceTest.php`:
|
||
|
||
```php
|
||
use App\Models\Station;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
it('upserts stations from API batch response', function (): void {
|
||
$apiStations = [
|
||
[
|
||
'node_id' => 'abc123',
|
||
'trading_name' => 'Village Garage',
|
||
'brand_name' => 'Village Garage',
|
||
'is_same_trading_and_brand_name' => true,
|
||
'is_motorway_service_station' => false,
|
||
'is_supermarket_service_station' => false,
|
||
'temporary_closure' => false,
|
||
'permanent_closure' => false,
|
||
'permanent_closure_date' => null,
|
||
'public_phone_number' => null,
|
||
'location' => [
|
||
'address_line_1' => '1 High Street',
|
||
'address_line_2' => null,
|
||
'city' => 'London',
|
||
'county' => null,
|
||
'country' => 'England',
|
||
'postcode' => 'SW1A 1AA',
|
||
'latitude' => 51.5,
|
||
'longitude' => -0.1,
|
||
],
|
||
'amenities' => [],
|
||
'opening_times'=> null,
|
||
'fuel_types' => ['E10', 'E5'],
|
||
],
|
||
];
|
||
|
||
$this->service->upsertStations($apiStations);
|
||
|
||
$station = Station::find('abc123');
|
||
expect($station)->not->toBeNull()
|
||
->and($station->trading_name)->toBe('Village Garage')
|
||
->and($station->postcode)->toBe('SW1A 1AA')
|
||
->and($station->lat)->toBe(51.5)
|
||
->and($station->is_supermarket)->toBeFalse();
|
||
});
|
||
|
||
it('tags supermarket stations during upsert', function (): void {
|
||
$apiStations = [[
|
||
'node_id' => 'tesco1',
|
||
'trading_name' => 'TESCO',
|
||
'brand_name' => 'TESCO',
|
||
'is_same_trading_and_brand_name' => true,
|
||
'is_motorway_service_station' => false,
|
||
'is_supermarket_service_station' => true,
|
||
'temporary_closure' => false,
|
||
'permanent_closure' => false,
|
||
'permanent_closure_date' => null,
|
||
'public_phone_number' => null,
|
||
'location' => [
|
||
'address_line_1' => '1 Tesco Way',
|
||
'address_line_2' => null,
|
||
'city' => 'Bristol',
|
||
'county' => null,
|
||
'country' => 'England',
|
||
'postcode' => 'BS1 1AA',
|
||
'latitude' => 51.45,
|
||
'longitude' => -2.6,
|
||
],
|
||
'amenities' => [],
|
||
'opening_times'=> null,
|
||
'fuel_types' => ['E10'],
|
||
]];
|
||
|
||
$this->service->upsertStations($apiStations);
|
||
|
||
expect(Station::find('tesco1')->is_supermarket)->toBeTrue();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to confirm it fails**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="upserts stations"
|
||
```
|
||
|
||
Expected: FAIL — `upsertStations` not found.
|
||
|
||
- [ ] **Step 3: Implement upsertStations**
|
||
|
||
Add to `FuelPriceService`:
|
||
|
||
```php
|
||
use App\Models\Station;
|
||
use Illuminate\Support\Carbon;
|
||
|
||
/** @param array<int, array<string, mixed>> $apiStations */
|
||
public function upsertStations(array $apiStations): void
|
||
{
|
||
$now = now();
|
||
$rows = [];
|
||
|
||
foreach ($apiStations as $data) {
|
||
$station = new Station([
|
||
'node_id' => $data['node_id'],
|
||
'trading_name' => $data['trading_name'],
|
||
'brand_name' => $data['brand_name'] ?? null,
|
||
'is_same_trading_and_brand' => $data['is_same_trading_and_brand_name'] ?? false,
|
||
'is_supermarket' => false,
|
||
'is_motorway_service_station' => $data['is_motorway_service_station'] ?? false,
|
||
'is_supermarket_service_station' => $data['is_supermarket_service_station'] ?? false,
|
||
'temporary_closure' => $data['temporary_closure'] ?? false,
|
||
'permanent_closure' => $data['permanent_closure'] ?? false,
|
||
'permanent_closure_date' => $data['permanent_closure_date'] ?? null,
|
||
'public_phone_number' => $data['public_phone_number'] ?? null,
|
||
'address_line_1' => $data['location']['address_line_1'] ?? null,
|
||
'address_line_2' => $data['location']['address_line_2'] ?? null,
|
||
'city' => $data['location']['city'] ?? null,
|
||
'county' => $data['location']['county'] ?? null,
|
||
'country' => $data['location']['country'] ?? null,
|
||
'postcode' => $data['location']['postcode'],
|
||
'lat' => $data['location']['latitude'],
|
||
'lng' => $data['location']['longitude'],
|
||
'amenities' => $data['amenities'] ?? [],
|
||
'opening_times' => $data['opening_times'] ?? null,
|
||
'fuel_types' => $data['fuel_types'] ?? [],
|
||
'last_seen_at' => $now,
|
||
]);
|
||
|
||
$this->taggingService->tag($station);
|
||
$rows[] = $station->getAttributes();
|
||
}
|
||
|
||
Station::upsert(
|
||
rows: $rows,
|
||
uniqueBy: ['node_id'],
|
||
update: array_keys($rows[0] ?? []),
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
|
||
```
|
||
|
||
Expected: 4 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php
|
||
git commit -m "feat: FuelPriceService station upsert with tagging"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: FuelPriceService — price ingestion
|
||
|
||
**Files:**
|
||
- Modify: `app/Services/FuelPriceService.php`
|
||
- Modify: `tests/Unit/Services/FuelPriceServiceTest.php`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Add to `tests/Unit/Services/FuelPriceServiceTest.php`:
|
||
|
||
```php
|
||
use App\Models\StationPrice;
|
||
use App\Models\StationPriceCurrent;
|
||
|
||
it('inserts new price row and upserts current when price changes', function (): void {
|
||
$station = Station::factory()->create(['node_id' => 'st001']);
|
||
|
||
// Existing current price
|
||
StationPriceCurrent::create([
|
||
'station_id' => 'st001',
|
||
'fuel_type' => 'e10',
|
||
'price_pence' => 14900, // 149.00p
|
||
'price_effective_at' => now()->subDay(),
|
||
'price_reported_at' => now()->subDay(),
|
||
'recorded_at' => now()->subDay(),
|
||
]);
|
||
|
||
$apiData = [[
|
||
'node_id' => 'st001',
|
||
'fuel_prices' => [[
|
||
'fuel_type' => 'E10',
|
||
'price' => 151.9, // changed price
|
||
'price_last_updated' => '2026-04-03T12:00:00.000Z',
|
||
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
|
||
]],
|
||
]];
|
||
|
||
$this->service->processPrices($apiData);
|
||
|
||
expect(StationPrice::where('station_id', 'st001')->count())->toBe(1);
|
||
expect(StationPriceCurrent::where('station_id', 'st001')->where('fuel_type', 'e10')->first()->price_pence)
|
||
->toBe(15190);
|
||
});
|
||
|
||
it('skips insert when price has not changed', function (): void {
|
||
$station = Station::factory()->create(['node_id' => 'st002']);
|
||
|
||
StationPriceCurrent::create([
|
||
'station_id' => 'st002',
|
||
'fuel_type' => 'e10',
|
||
'price_pence' => 15190, // same as incoming
|
||
'price_effective_at' => now()->subDay(),
|
||
'price_reported_at' => now()->subDay(),
|
||
'recorded_at' => now()->subDay(),
|
||
]);
|
||
|
||
$apiData = [[
|
||
'node_id' => 'st002',
|
||
'fuel_prices' => [[
|
||
'fuel_type' => 'E10',
|
||
'price' => 151.9,
|
||
'price_last_updated' => '2026-04-03T12:00:00.000Z',
|
||
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
|
||
]],
|
||
]];
|
||
|
||
$this->service->processPrices($apiData);
|
||
|
||
expect(StationPrice::where('station_id', 'st002')->count())->toBe(0);
|
||
});
|
||
|
||
it('inserts price for station with no existing current price', function (): void {
|
||
$station = Station::factory()->create(['node_id' => 'st003']);
|
||
|
||
$apiData = [[
|
||
'node_id' => 'st003',
|
||
'fuel_prices' => [[
|
||
'fuel_type' => 'E5',
|
||
'price' => 159.9,
|
||
'price_last_updated' => '2026-04-03T12:00:00.000Z',
|
||
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
|
||
]],
|
||
]];
|
||
|
||
$this->service->processPrices($apiData);
|
||
|
||
expect(StationPrice::where('station_id', 'st003')->count())->toBe(1);
|
||
expect(StationPriceCurrent::where('station_id', 'st003')->where('fuel_type', 'e5')->first()->price_pence)
|
||
->toBe(15990);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="price"
|
||
```
|
||
|
||
Expected: FAIL — `processPrices` not found.
|
||
|
||
- [ ] **Step 3: Implement processPrices**
|
||
|
||
Add to `FuelPriceService`:
|
||
|
||
```php
|
||
use App\Enums\FuelType;
|
||
use App\Models\StationPrice;
|
||
use App\Models\StationPriceCurrent;
|
||
|
||
/** @param array<int, array<string, mixed>> $apiStations */
|
||
public function processPrices(array $apiStations): void
|
||
{
|
||
$now = now();
|
||
|
||
// Load current prices keyed by "station_id:fuel_type" for fast dedup lookup
|
||
$stationIds = array_column($apiStations, 'node_id');
|
||
$currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds)
|
||
->get()
|
||
->keyBy(fn ($row) => $row->station_id.':'.$row->fuel_type->value);
|
||
|
||
$newHistoryRows = [];
|
||
$upsertCurrentRows = [];
|
||
|
||
foreach ($apiStations as $station) {
|
||
foreach ($station['fuel_prices'] ?? [] as $fp) {
|
||
$fuelType = FuelType::fromApiValue($fp['fuel_type']);
|
||
$pricePence = (int) round($fp['price'] * 100);
|
||
$key = $station['node_id'].':'.$fuelType->value;
|
||
|
||
// Skip if price unchanged
|
||
if (isset($currentPrices[$key]) && $currentPrices[$key]->price_pence === $pricePence) {
|
||
continue;
|
||
}
|
||
|
||
$row = [
|
||
'station_id' => $station['node_id'],
|
||
'fuel_type' => $fuelType->value,
|
||
'price_pence' => $pricePence,
|
||
'price_effective_at' => Carbon::parse($fp['price_change_effective_timestamp']),
|
||
'price_reported_at' => Carbon::parse($fp['price_last_updated']),
|
||
'recorded_at' => $now,
|
||
];
|
||
|
||
$newHistoryRows[] = $row;
|
||
$upsertCurrentRows[] = $row;
|
||
}
|
||
}
|
||
|
||
if ($newHistoryRows !== []) {
|
||
StationPrice::insert($newHistoryRows);
|
||
}
|
||
|
||
if ($upsertCurrentRows !== []) {
|
||
StationPriceCurrent::upsert(
|
||
rows: $upsertCurrentRows,
|
||
uniqueBy: ['station_id', 'fuel_type'],
|
||
update: ['price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'],
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run all service tests**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php
|
||
git commit -m "feat: FuelPriceService price ingestion with deduplication"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: FuelPriceService — full and incremental poll methods
|
||
|
||
**Files:**
|
||
- Modify: `app/Services/FuelPriceService.php`
|
||
- Modify: `tests/Unit/Services/FuelPriceServiceTest.php`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Add to `tests/Unit/Services/FuelPriceServiceTest.php`:
|
||
|
||
```php
|
||
use App\Events\PricesUpdatedEvent;
|
||
use Illuminate\Support\Facades\Event;
|
||
|
||
it('incremental poll processes prices from no-batch endpoint', function (): void {
|
||
Event::fake();
|
||
Station::factory()->create(['node_id' => 'st010']);
|
||
|
||
Http::fake([
|
||
'*/oauth/generate_access_token' => Http::response(['data' => ['access_token' => 'tok', 'expires_in' => 3600]]),
|
||
'*/pfs/fuel-prices' => Http::response([[
|
||
'node_id' => 'st010',
|
||
'fuel_prices' => [[
|
||
'fuel_type' => 'E10',
|
||
'price' => 149.9,
|
||
'price_last_updated' => '2026-04-03T12:00:00.000Z',
|
||
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
|
||
]],
|
||
]]),
|
||
]);
|
||
|
||
$this->service->pollIncremental();
|
||
|
||
expect(StationPrice::count())->toBe(1);
|
||
Event::assertDispatched(PricesUpdatedEvent::class);
|
||
});
|
||
|
||
it('full poll iterates all batches and stops when fewer than 500 records returned', function (): void {
|
||
Event::fake();
|
||
Station::factory()->create(['node_id' => 'full001']);
|
||
|
||
$pfsStation = [[
|
||
'node_id' => 'full001',
|
||
'trading_name' => 'Test Garage',
|
||
'brand_name' => 'Test Garage',
|
||
'is_same_trading_and_brand_name' => true,
|
||
'is_motorway_service_station' => false,
|
||
'is_supermarket_service_station' => false,
|
||
'temporary_closure' => false,
|
||
'permanent_closure' => false,
|
||
'permanent_closure_date' => null,
|
||
'public_phone_number' => null,
|
||
'location' => [
|
||
'address_line_1' => '1 Road',
|
||
'address_line_2' => null,
|
||
'city' => 'London',
|
||
'county' => null,
|
||
'country' => 'England',
|
||
'postcode' => 'E1 1AA',
|
||
'latitude' => 51.5,
|
||
'longitude' => -0.1,
|
||
],
|
||
'amenities' => [],
|
||
'opening_times'=> null,
|
||
'fuel_types' => ['E10'],
|
||
]];
|
||
|
||
Http::fake([
|
||
'*/oauth/generate_access_token' => Http::response(['data' => ['access_token' => 'tok', 'expires_in' => 3600]]),
|
||
'*/pfs?batch-number=1' => Http::response($pfsStation), // < 500 = last batch
|
||
'*/pfs/fuel-prices?batch-number=1' => Http::response([[
|
||
'node_id' => 'full001',
|
||
'fuel_prices' => [[
|
||
'fuel_type' => 'E10',
|
||
'price' => 149.9,
|
||
'price_last_updated' => '2026-04-03T12:00:00.000Z',
|
||
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
|
||
]],
|
||
]]),
|
||
]);
|
||
|
||
$this->service->pollFull();
|
||
|
||
expect(Station::where('node_id', 'full001')->exists())->toBeTrue();
|
||
expect(StationPrice::count())->toBe(1);
|
||
Event::assertDispatched(PricesUpdatedEvent::class);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="poll"
|
||
```
|
||
|
||
Expected: FAIL — `PricesUpdatedEvent` and poll methods not found.
|
||
|
||
- [ ] **Step 3: Create PricesUpdatedEvent**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Events;
|
||
|
||
class PricesUpdatedEvent
|
||
{
|
||
public function __construct(
|
||
public readonly int $stationsProcessed,
|
||
public readonly int $priceChanges,
|
||
public readonly bool $isFullRefresh,
|
||
) {}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement pollIncremental and pollFull**
|
||
|
||
Add to `FuelPriceService`:
|
||
|
||
```php
|
||
use App\Events\PricesUpdatedEvent;
|
||
|
||
public function pollIncremental(): void
|
||
{
|
||
$token = $this->getAccessToken();
|
||
$response = Http::timeout(30)
|
||
->withToken($token)
|
||
->get(config('services.fuel_finder.base_url').'/pfs/fuel-prices');
|
||
|
||
$stations = $response->json() ?? [];
|
||
$this->processPrices($stations);
|
||
|
||
PricesUpdatedEvent::dispatch(
|
||
stationsProcessed: count($stations),
|
||
priceChanges: 0,
|
||
isFullRefresh: false,
|
||
);
|
||
}
|
||
|
||
public function pollFull(): void
|
||
{
|
||
$token = $this->getAccessToken();
|
||
$base = config('services.fuel_finder.base_url');
|
||
$batch = 1;
|
||
|
||
// Full station metadata refresh
|
||
do {
|
||
$stations = Http::timeout(30)
|
||
->withToken($token)
|
||
->get("{$base}/pfs", ['batch-number' => $batch])
|
||
->json() ?? [];
|
||
|
||
if ($stations !== []) {
|
||
$this->upsertStations($stations);
|
||
}
|
||
|
||
$batch++;
|
||
} while (count($stations) >= 500);
|
||
|
||
// Full price refresh
|
||
$batch = 1;
|
||
$totalPriceStations = 0;
|
||
|
||
do {
|
||
$priceStations = Http::timeout(30)
|
||
->withToken($token)
|
||
->get("{$base}/pfs/fuel-prices", ['batch-number' => $batch])
|
||
->json() ?? [];
|
||
|
||
if ($priceStations !== []) {
|
||
$this->processPrices($priceStations);
|
||
$totalPriceStations += count($priceStations);
|
||
}
|
||
|
||
$batch++;
|
||
} while (count($priceStations) >= 500);
|
||
|
||
PricesUpdatedEvent::dispatch(
|
||
stationsProcessed: $totalPriceStations,
|
||
priceChanges: 0,
|
||
isFullRefresh: true,
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run all service tests**
|
||
|
||
```bash
|
||
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/Services/FuelPriceService.php app/Events/PricesUpdatedEvent.php tests/Unit/Services/FuelPriceServiceTest.php
|
||
git commit -m "feat: FuelPriceService incremental and full poll methods"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: PollFuelPricesCommand
|
||
|
||
**Files:**
|
||
- Create: `app/Console/Commands/PollFuelPricesCommand.php`
|
||
- Create: `tests/Feature/Commands/PollFuelPricesCommandTest.php`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Services\FuelPriceService;
|
||
use App\Services\StationTaggingService;
|
||
|
||
it('runs incremental poll by default', function (): void {
|
||
$service = Mockery::mock(FuelPriceService::class);
|
||
$service->expects('pollIncremental')->once();
|
||
$service->shouldNotReceive('pollFull');
|
||
app()->instance(FuelPriceService::class, $service);
|
||
|
||
$this->artisan('fuel:poll')
|
||
->assertSuccessful();
|
||
});
|
||
|
||
it('runs full poll with --full flag', function (): void {
|
||
$service = Mockery::mock(FuelPriceService::class);
|
||
$service->expects('pollFull')->once();
|
||
$service->shouldNotReceive('pollIncremental');
|
||
app()->instance(FuelPriceService::class, $service);
|
||
|
||
$this->artisan('fuel:poll --full')
|
||
->assertSuccessful();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php
|
||
```
|
||
|
||
Expected: FAIL — command not found.
|
||
|
||
- [ ] **Step 3: Implement PollFuelPricesCommand**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Services\FuelPriceService;
|
||
use Illuminate\Console\Command;
|
||
|
||
class PollFuelPricesCommand extends Command
|
||
{
|
||
protected $signature = 'fuel:poll {--full : Run a full refresh of all stations and prices}';
|
||
protected $description = 'Poll the UK Fuel Finder API for price updates';
|
||
|
||
public function handle(FuelPriceService $service): int
|
||
{
|
||
if ($this->option('full')) {
|
||
$this->info('Running full refresh...');
|
||
$service->pollFull();
|
||
$this->info('Full refresh complete.');
|
||
} else {
|
||
$this->info('Running incremental poll...');
|
||
$service->pollIncremental();
|
||
$this->info('Incremental poll complete.');
|
||
}
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php
|
||
```
|
||
|
||
Expected: 2 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Console/Commands/PollFuelPricesCommand.php tests/Feature/Commands/PollFuelPricesCommandTest.php
|
||
git commit -m "feat: add fuel:poll artisan command"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: ArchiveOldPricesCommand
|
||
|
||
**Files:**
|
||
- Create: `app/Console/Commands/ArchiveOldPricesCommand.php`
|
||
- Create: `tests/Feature/Commands/ArchiveOldPricesCommandTest.php`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Models\Station;
|
||
use App\Models\StationPrice;
|
||
use App\Models\StationPriceArchive;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
it('moves prices older than 12 months to archive', function (): void {
|
||
$station = Station::factory()->create();
|
||
|
||
// Old price — should be archived
|
||
StationPrice::factory()->create([
|
||
'station_id' => $station->node_id,
|
||
'price_effective_at' => now()->subMonths(13),
|
||
'price_reported_at' => now()->subMonths(13),
|
||
'recorded_at' => now()->subMonths(13),
|
||
]);
|
||
|
||
// Recent price — should stay
|
||
StationPrice::factory()->create([
|
||
'station_id' => $station->node_id,
|
||
'price_effective_at' => now()->subMonths(6),
|
||
'price_reported_at' => now()->subMonths(6),
|
||
'recorded_at' => now()->subMonths(6),
|
||
]);
|
||
|
||
$this->artisan('fuel:archive')->assertSuccessful();
|
||
|
||
expect(StationPrice::count())->toBe(1);
|
||
expect(StationPriceArchive::count())->toBe(1);
|
||
});
|
||
|
||
it('does nothing when no old prices exist', function (): void {
|
||
Station::factory()->create();
|
||
|
||
$this->artisan('fuel:archive')
|
||
->expectsOutput('No prices to archive.')
|
||
->assertSuccessful();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php
|
||
```
|
||
|
||
Expected: FAIL — command not found.
|
||
|
||
- [ ] **Step 3: Implement ArchiveOldPricesCommand**
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\StationPrice;
|
||
use App\Models\StationPriceArchive;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class ArchiveOldPricesCommand extends Command
|
||
{
|
||
protected $signature = 'fuel:archive';
|
||
protected $description = 'Move station price history older than 12 months to the archive table';
|
||
|
||
public function handle(): int
|
||
{
|
||
$cutoff = Carbon::now()->subMonths(12);
|
||
|
||
$count = StationPrice::where('price_effective_at', '<', $cutoff)->count();
|
||
|
||
if ($count === 0) {
|
||
$this->info('No prices to archive.');
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
$this->info("Archiving {$count} price records older than {$cutoff->toDateString()}...");
|
||
|
||
// Move in chunks to avoid memory issues
|
||
StationPrice::where('price_effective_at', '<', $cutoff)
|
||
->chunkById(1000, function ($prices): void {
|
||
$rows = $prices->map->getAttributes()->toArray();
|
||
DB::transaction(function () use ($rows, $prices): void {
|
||
StationPriceArchive::insert($rows);
|
||
StationPrice::whereIn('id', $prices->pluck('id'))->delete();
|
||
});
|
||
});
|
||
|
||
$this->info('Archive complete.');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php
|
||
```
|
||
|
||
Expected: 2 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/Console/Commands/ArchiveOldPricesCommand.php tests/Feature/Commands/ArchiveOldPricesCommandTest.php
|
||
git commit -m "feat: add fuel:archive command for price rotation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Register scheduler
|
||
|
||
**Files:**
|
||
- Modify: `bootstrap/app.php`
|
||
|
||
- [ ] **Step 1: Register scheduled commands**
|
||
|
||
In `bootstrap/app.php`, add `->withSchedule(...)` after `->withMiddleware(...)`:
|
||
|
||
```php
|
||
use App\Console\Commands\ArchiveOldPricesCommand;
|
||
use App\Console\Commands\PollFuelPricesCommand;
|
||
use Illuminate\Console\Scheduling\Schedule;
|
||
|
||
return Application::configure(basePath: dirname(__DIR__))
|
||
->withRouting(
|
||
web: __DIR__.'/../routes/web.php',
|
||
commands: __DIR__.'/../routes/console.php',
|
||
health: '/up',
|
||
)
|
||
->withMiddleware(function (Middleware $middleware): void {
|
||
//
|
||
})
|
||
->withSchedule(function (Schedule $schedule): void {
|
||
$schedule->command(PollFuelPricesCommand::class)
|
||
->everyFifteenMinutes()
|
||
->withoutOverlapping()
|
||
->runInBackground();
|
||
|
||
$schedule->command(PollFuelPricesCommand::class, ['--full'])
|
||
->dailyAt('03:00')
|
||
->withoutOverlapping();
|
||
|
||
$schedule->command(ArchiveOldPricesCommand::class)
|
||
->monthlyOn(1, '04:00');
|
||
})
|
||
->withExceptions(function (Exceptions $exceptions): void {
|
||
//
|
||
})->create();
|
||
```
|
||
|
||
- [ ] **Step 2: Verify scheduler shows commands**
|
||
|
||
```bash
|
||
php artisan schedule:list
|
||
```
|
||
|
||
Expected: `fuel:poll` (every 15 min), `fuel:poll --full` (daily 3am), `fuel:archive` (monthly) listed.
|
||
|
||
- [ ] **Step 3: Run full test suite**
|
||
|
||
```bash
|
||
php artisan test
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add bootstrap/app.php
|
||
git commit -m "feat: register fuel polling and archive commands in scheduler"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage check:**
|
||
- ✅ OAuth token fetch + cache (`fuel_finder_access_token`, TTL 3540s)
|
||
- ✅ Token refresh endpoint noted in api-data.md (used in `getAccessToken` on cache miss)
|
||
- ✅ Station upsert from `/pfs` with all API fields
|
||
- ✅ `StationTaggingService` brand detection
|
||
- ✅ Price deduplication via `station_prices_current`
|
||
- ✅ Both timestamps stored (`price_effective_at`, `price_reported_at`)
|
||
- ✅ `station_prices` partitioned monthly, composite PK
|
||
- ✅ `station_prices_archive` identical schema, no partition
|
||
- ✅ Incremental poll (every 15 min) + full poll (daily)
|
||
- ✅ `PricesUpdatedEvent` dispatched after each poll
|
||
- ✅ `fuel:archive` command with chunked move
|
||
- ✅ Scheduler registration with `withoutOverlapping()`
|
||
|
||
**No placeholders found.**
|
||
|
||
**Type consistency confirmed** — `FuelType::fromApiValue()` used consistently in `processPrices`, `fuel_type` stored as enum value (string) in DB rows.
|