diff --git a/.dockerignore b/.dockerignore index 4b508fb..d5e2007 100644 --- a/.dockerignore +++ b/.dockerignore @@ -35,3 +35,10 @@ Thumbs.db # Wrong lockfile: this is an npm repo (package-lock.json) pnpm-lock.yaml yarn.lock + +# Docker and env artifacts +Dockerfile* +.dockerignore +*.log* +.env* +coverage/ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3ffe223..4db9b72 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -137,5 +137,5 @@ From `src/BikeTracking.Frontend`: For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -[specs/029-co2-savings-dashboard/plan.md](../specs/029-co2-savings-dashboard/plan.md) +[specs/030-gas-price-grade-cache/plan.md](../specs/030-gas-price-grade-cache/plan.md) diff --git a/.specify/feature.json b/.specify/feature.json index 243a723..6829c62 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/029-co2-savings-dashboard" + "feature_directory": "specs/030-gas-price-grade-cache" } diff --git a/specs/030-gas-price-grade-cache/checklists/requirements.md b/specs/030-gas-price-grade-cache/checklists/requirements.md new file mode 100644 index 0000000..782edc3 --- /dev/null +++ b/specs/030-gas-price-grade-cache/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Gas Price Grade Selection & Cache Refresh Policy + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- No [NEEDS CLARIFICATION] markers were needed: reasonable defaults were available for the two open questions (default grade = "Regular"; 3-day duration interpreted as a rolling freshness window measured from retrieval time) and are documented in the Assumptions section. +- The "Current Behavior (Investigation Findings)" section is additional context (not part of the standard template) documenting the codebase audit performed before writing requirements, including the discrepancy that the existing cache has no expiry at all (not merely a wrong duration) and that the data source is "all grades," not "premium" or "regular unleaded" as previously documented. +- All items pass; spec is ready for `/speckit.clarify` (optional) or `/speckit.plan`. diff --git a/specs/030-gas-price-grade-cache/contracts/gas-price-grade-cache-contract.md b/specs/030-gas-price-grade-cache/contracts/gas-price-grade-cache-contract.md new file mode 100644 index 0000000..2c3ed61 --- /dev/null +++ b/specs/030-gas-price-grade-cache/contracts/gas-price-grade-cache-contract.md @@ -0,0 +1,117 @@ +# Contract: Gas Price Grade Selection & Cache Refresh Policy + +This feature modifies two existing endpoints/resources — it does not introduce a new endpoint. + +## 1. `GET /api/rides/gas-price` + +**Existing route, extended.** + +### Request + +| Parameter | Location | Type | Required | Notes | +|-----------|----------|------|----------|-------| +| `date` | query | `string` (`YYYY-MM-DD`) | yes | unchanged | +| `grade` | query | `string` (`"Regular"` \| `"Premium"`, case-insensitive) | **no (NEW)** | When present and valid, overrides the rider's saved `GasGrade` preference for this call only. When omitted, the rider's saved preference is used (or `"Regular"` if the rider has no settings row). | + +### Response `200 OK` — `GasPriceResponse` (extended) + +```json +{ + "date": "2026-08-24", + "pricePerGallon": 3.219, + "isAvailable": true, + "dataSource": "Source: U.S. Energy Information Administration (EIA)", + "grade": "Regular" +} +``` + +| Field | Type | Change | Notes | +|-------|------|--------|-------| +| `date` | `string` | unchanged | | +| `pricePerGallon` | `number \| null` | unchanged | | +| `isAvailable` | `boolean` | unchanged | | +| `dataSource` | `string \| null` | unchanged | | +| `grade` | `string` | **NEW** | The grade actually used to resolve this response (`"Regular"` or `"Premium"`) — either the `grade` query-param override or the rider's saved preference. Always present, even when `isAvailable` is `false`, so callers/tests can confirm which grade was attempted. | + +### Response `400 Bad Request` — `ErrorResponse` (extended condition) + +- Existing: missing/invalid `date` → `INVALID_REQUEST`. +- **New**: `grade` present but not `"Regular"`/`"Premium"` (case-insensitive) → `400 INVALID_REQUEST` with the same `ErrorResponse` shape, e.g.: + ```json + { "code": "INVALID_REQUEST", "message": "grade query parameter, if provided, must be 'Regular' or 'Premium'." } + ``` + +### Behavioral Rules + +1. Effective grade resolution order: `grade` query param (if valid) → rider's saved `UserSettingsEntity.GasGrade` → `"Regular"` (no-settings-row default). This mirrors the existing `apiKey` resolution precedent (`userSettings?.EiaGasApiKey` → app-config fallback) already in `GetGasPrice`. +2. Cache lookup/write always keys on `(WeekStartDate, effectiveGrade)` — never on `date`/`WeekStartDate` alone (FR-004). +3. A cached row younger than 3 days (`RetrievedAtUtc`) for the effective `(week, grade)` is returned without any external call (FR-006). +4. A cached row 3+ days old triggers a de-duplicated refresh attempt (FR-007/FR-007a); on success the new price/timestamp replace the row; on failure the prior stale price is still returned (FR-009). +5. A pre-feature legacy row (`Grade = NULL`) for the same `WeekStartDate` is never returned or matched — a lookup that only finds a legacy row is treated as a full cache miss and triggers a fresh external fetch (FR-004a). +6. Manually overriding `grade` via the query parameter never persists to `UserSettingsEntity.GasGrade` (FR-011 — "overrides... for that single request"). + +## 2. `GET /api/users/settings` and `PUT /api/users/settings` (existing settings endpoints backing `UserSettingsService`) + +> Route names as currently exposed by `UsersEndpoints`/equivalent; only the payload shape changes here. + +### `UserSettingsView` / `UserSettingsResponse` (GET) — extended + +```json +{ + "hasSettings": true, + "settings": { + "averageCarMpg": 32.5, + "yearlyGoalMiles": 3000, + "oilChangePrice": 45, + "mileageRateCents": 67, + "locationLabel": "Downtown", + "latitude": 39.1, + "longitude": -84.5, + "dashboardGallonsAvoidedEnabled": true, + "dashboardGoalProgressEnabled": true, + "updatedAtUtc": "2026-08-27T12:00:00Z", + "weatherApiKey": null, + "eiaGasApiKey": null, + "gasGrade": "Premium" + } +} +``` + +| Field | Type | Change | Notes | +|-------|------|--------|-------| +| `gasGrade` | `string` | **NEW** | Always present, always `"Regular"` or `"Premium"`. `"Regular"` for a rider with `hasSettings: false` (no row yet) or a post-feature settings row that has never set it explicitly; `"Premium"` for any rider whose settings row existed before this feature's migration ran. | + +### `UserSettingsUpsertRequest` (PUT) — extended + +```json +{ + "gasGrade": "Regular" +} +``` + +| Field | Type | Change | Notes | +|-------|------|--------|-------| +| `gasGrade` | `string?` | **NEW** | Optional on the wire, following the existing partial-update convention (`providedFields`). When provided, MUST be `"Regular"` or `"Premium"` (case handling left to the same validation approach used for other constrained fields) or the request is rejected with the existing `UsersErrorCodes.ValidationFailed` shape. When omitted, the rider's existing `GasGrade` is left unchanged. | + +### Behavioral Rules + +1. Changing `gasGrade` never retroactively alters gas prices already stored on previously recorded rides (FR-005) — it only affects the *next* `GET /api/rides/gas-price` call's default grade resolution. +2. The migration backfills `gasGrade = "Premium"` for every `UserSettings` row that exists at migration time; the application-level default of `"Regular"` applies only to rows created via `UserSettingsService` after the migration has run (FR-002 vs. FR-002a). + +## Consumer Rules for Spec #030 + +1. `RidesEndpointsTests` (backend) must assert: (a) omitting `grade` uses the rider's saved preference, (b) a valid `grade` override changes the returned `grade`/price series without persisting to settings, (c) an invalid `grade` value returns `400 INVALID_REQUEST`, (d) the response always includes `grade` even when `isAvailable` is `false`. +2. `GasPriceLookupServiceTests` (backend) must assert: (a) a fresh (`< 3 days`) cached row for `(week, grade)` is returned without an HTTP call, (b) a stale (`>= 3 days`) row triggers exactly one HTTP call even under simulated concurrent callers for the same `(week, grade)`, (c) a failed refresh returns the prior stale price rather than `null`, (d) a legacy `Grade = NULL` row is never returned for a grade-aware query and instead triggers a fresh fetch, (e) `Regular` and `Premium` requests for the same week produce two independent cache rows. +3. `UserSettingsServiceTests` (backend) must assert: (a) a rider with no settings row sees `gasGrade: "Regular"` as the read-side default, (b) saving `gasGrade` persists and round-trips, (c) an invalid `gasGrade` value is rejected. +4. Migration tests / manual verification must confirm all pre-existing `UserSettings` rows read `gasGrade: "Premium"` immediately after the migration runs, with no manual intervention. +5. Frontend `SettingsPage` tests must assert the grade selector renders, defaults per the above rules, and saves via `users-api.ts`. + +## Formula/Policy Requirements (Spec Source of Truth) + +- Freshness window: `now - RetrievedAtUtc < 3 days` ⇒ fresh (reuse, no external call); `>= 3 days` ⇒ stale (attempt refresh) (FR-006/FR-007). +- Cache key: `(WeekStartDate, Grade)`, `Grade ∈ {"Regular", "Premium"}` for all post-feature rows (FR-004). +- Concurrency: at most one external call per `(week, grade)` per staleness event (FR-007a/SC-003). +- Legacy rows (`Grade = NULL`): permanently inert, never matched, never migrated (FR-004a). +- Settings defaults: new rows → `"Regular"` (FR-002); pre-existing rows at migration time → `"Premium"` (FR-002a). + +Backend contracts (`RidesContracts.cs`, `UsersContracts.cs`) and frontend TypeScript models (`ridesService.ts`, `users-api.ts`) must stay synchronized for these new fields in the same change. diff --git a/specs/030-gas-price-grade-cache/data-model.md b/specs/030-gas-price-grade-cache/data-model.md new file mode 100644 index 0000000..1c4698f --- /dev/null +++ b/specs/030-gas-price-grade-cache/data-model.md @@ -0,0 +1,74 @@ +# Phase 1 Data Model: Gas Price Grade Selection & Cache Refresh Policy + +## Modified Entities + +### `GasPriceLookupEntity` (table: `GasPriceLookups`) + +| Field | Type | Change | Rules | +|-------|------|--------|-------| +| `GasPriceLookupId` | `int` (PK) | unchanged | identity | +| `PriceDate` | `DateOnly` | unchanged | required | +| `WeekStartDate` | `DateOnly` | unchanged | required; Sunday-of-ISO-week per `GasPriceWeekKeyHelper` | +| `Grade` | `string?` | **NEW** | nullable at DB level; `NULL` only ever appears on pre-feature legacy rows. Every row written by post-feature code sets this to the literal `"Regular"` or `"Premium"` — never `NULL`, never any other value. | +| `PricePerGallon` | `decimal` | unchanged | required, precision (10,4), must be `> 0` (existing validation) | +| `DataSource` | `string` | unchanged | required, max length 64 (existing value: `"EIA_EPM0_NUS_Weekly"` for legacy rows; new rows continue to use a source label reflecting the grade-specific series, e.g. `"EIA_EPMR_NUS_Weekly"` / `"EIA_EPMP_NUS_Weekly"`) | +| `EiaPeriodDate` | `DateOnly` | unchanged | required | +| `RetrievedAtUtc` | `DateTime` | unchanged (usage extended) | required; now also read (not just written) — drives the 3-day freshness check via `TimeProvider.GetUtcNow()` | + +**Index changes**: +- Remove: `HasIndex(x => x.PriceDate).IsUnique()` — **unchanged, kept as-is** (still present; grade does not change the price-date uniqueness expectation for a *given* week+grade... actually `PriceDate` unique index predates grade-awareness and is superseded — see note below). +- Remove: `HasIndex(x => x.WeekStartDate).IsUnique()`. +- Add: `HasIndex(x => new { x.WeekStartDate, x.Grade }).IsUnique()` — the new cache key. Because SQLite indexes treat each `NULL` as distinct, multiple legacy rows (`Grade = NULL`) for different (or even the same) `WeekStartDate` do not violate this unique index amongst themselves, and never collide with new graded rows. + + > **Note on the existing `PriceDate` unique index**: The current schema has *two* unique indexes (`PriceDate` and `WeekStartDate` independently), which is stricter than necessary now that the true cache key is `(WeekStartDate, Grade)` — a given `PriceDate` could legitimately need two rows (one per grade) once grade-awareness lands. The migration MUST drop the standalone unique index on `PriceDate` (replacing enforcement of "one row per priced day" with the new composite key), since keeping it would block writing both a Regular and a Premium row that happen to share the same representative `PriceDate` for a given lookup. `PriceDate` remains a required, non-unique column. + +**Validation/Rules**: +- `Grade`, when non-null, MUST be one of `"Regular"` / `"Premium"` (validated in `EiaGasPriceLookupService`, mirroring how `UserSettingsService` validates `GasGrade`). +- A row is considered **fresh** iff `TimeProvider.GetUtcNow().UtcDateTime - RetrievedAtUtc < TimeSpan.FromDays(3)`; **stale** otherwise (FR-006/FR-007). +- A stale row is never deleted outright — it is only replaced in-place (same `GasPriceLookupId`, updated `PricePerGallon`/`DataSource`/`EiaPeriodDate`/`RetrievedAtUtc`) when a refresh succeeds and returns a valid (`> 0`) price (FR-008/FR-010); on refresh failure, the stale row is returned unchanged (FR-009). + +### `UserSettingsEntity` (table: `UserSettings`) + +| Field | Type | Change | Rules | +|-------|------|--------|-------| +| `GasGrade` | `string` | **NEW** | non-nullable; allowed values `"Regular"` / `"Premium"`; CLR default `"Regular"` for newly-constructed rows (FR-002); pre-existing rows backfilled to `"Premium"` by the migration (FR-002a) | + +**New CHECK constraint** (mirroring existing `CK_UserSettings_*` pattern): +```sql +CK_UserSettings_GasGrade_Valid: "GasGrade" IN ('Regular', 'Premium') +``` + +**Validation/Rules**: +- `UserSettingsService.SaveAsync` treats `GasGrade` like other provided-fields-aware settings (only updated when explicitly included in `providedFields`, per the existing partial-update convention), rejecting any value outside `{"Regular", "Premium"}` with the existing validation-failure result shape (`UserSettingsResult`/`UsersErrorCodes.ValidationFailed`). +- `UserSettingsService.GetAsync` for a rider with no existing settings row continues to report `HasSettings: false`; the *view's* `GasGrade` in that no-row case is `"Regular"` (the FR-002 default), never `null` and never `"Premium"` (the `"Premium"` backfill only applies to rows that already existed at migration time — a rider who signs up after the feature ships and has never saved settings sees `"Regular"`). + +## Migration: `AddGasGradeAndCacheRefreshPolicy` (name illustrative; follow existing `yyyyMMddHHmmss_Description` convention) + +1. `AddColumn("Grade")` on `GasPriceLookups`, nullable, no default (legacy rows become `NULL`). +2. `DropIndex` on `GasPriceLookups.PriceDate` (unique) and `GasPriceLookups.WeekStartDate` (unique). +3. `CreateIndex` unique on `GasPriceLookups (WeekStartDate, Grade)`. +4. `AddColumn("GasGrade")` on `UserSettings`, non-nullable, with a migration-time default of `'Premium'` applied via the column-add default (or an explicit `UPDATE "UserSettings" SET "GasGrade" = 'Premium'` immediately after adding the column with a temporary default), so every row that existed before this migration ends up with `"Premium"` explicitly (FR-002a), and the column's ongoing application-level default for rows inserted afterward is `"Regular"` (enforced in `UserSettingsService`, not as a changing DB default, to avoid a second migration if the default logic is later revisited). +5. Add CHECK constraint `CK_UserSettings_GasGrade_Valid`. + +**Rollback consideration**: Down-migration removes the CHECK constraint, drops `GasGrade`, drops the composite unique index, drops `Grade`, and restores the two prior standalone unique indexes on `GasPriceLookups` — acceptable since this is a reversible schema change with no destructive data loss beyond the (already-inert) `Grade` values. + +## Contract Shape Changes + +### `GasPriceResponse` (`src/BikeTracking.Api/Contracts/RidesContracts.cs`) +- **Add**: `Grade: string` — the grade actually used for this lookup (resolved from the query-param override or the rider's saved preference), so the frontend/tests can confirm which grade produced the returned price, even when `IsAvailable` is `false`. + +### `GetGasPrice` endpoint (`GET /api/rides/gas-price`) +- **Add**: optional query parameter `grade` (`string`, `"Regular"` or `"Premium"`, case-insensitive). When present and valid, overrides the rider's saved `GasGrade` for this single call only (not persisted). When omitted, the rider's saved `UserSettingsEntity.GasGrade` is used (defaulting to `"Regular"` if the rider has no settings row at all — same default as Settings). An invalid `grade` value (anything other than `"Regular"`/`"Premium"`) returns the existing `400 INVALID_REQUEST` shape. + +### `UserSettingsUpsertRequest` / `UserSettingsView` (`src/BikeTracking.Api/Contracts/UsersContracts.cs`) +- **Add**: `GasGrade: string?` on both records, following the same optional/partial-update convention as `WeatherApiKey`/`EiaGasApiKey` (nullable on the wire; validated/defaulted server-side). + +## Presentation State Rules (Frontend) + +- `SettingsPage` renders a "Gas Grade" selector (e.g., a two-option radio group or ` setGasGrade(event.target.value as 'Regular' | 'Premium')} + > + + + + +
{ await expect(ridesService.recordRide(request)).rejects.toThrow(); }); + it("getGasPrice includes optional grade query parameter when provided", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + date: "2026-03-31", + pricePerGallon: 3.486, + isAvailable: true, + dataSource: "Source: U.S. Energy Information Administration (EIA)", + grade: "Premium", + }, + true, + ), + ); + + const result = await ridesService.getGasPrice("2026-03-31", "Premium"); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("/api/rides/gas-price"); + expect(url).toContain("date=2026-03-31"); + expect(url).toContain("grade=Premium"); + expect(result.grade).toBe("Premium"); + }); + it("should return ride presets from GET /api/rides/presets", async () => { const response = { presets: [ diff --git a/src/BikeTracking.Frontend/src/services/ridesService.ts b/src/BikeTracking.Frontend/src/services/ridesService.ts index 8410351..9190a02 100644 --- a/src/BikeTracking.Frontend/src/services/ridesService.ts +++ b/src/BikeTracking.Frontend/src/services/ridesService.ts @@ -50,6 +50,7 @@ export interface GasPriceResponse { pricePerGallon: number | null; isAvailable: boolean; dataSource: string | null; + grade: "Regular" | "Premium"; } export interface RideWeatherResponse { @@ -295,14 +296,19 @@ export async function recordRide( return response.json(); } -export async function getGasPrice(date: string): Promise { - const response = await fetch( - `${getApiBaseUrl()}/api/rides/gas-price?date=${encodeURIComponent(date)}`, - { - method: "GET", - headers: getAuthHeaders(), - }, - ); +export async function getGasPrice( + date: string, + grade?: "Regular" | "Premium", +): Promise { + const params = new URLSearchParams({ date }); + if (grade) { + params.set("grade", grade); + } + + const response = await fetch(`${getApiBaseUrl()}/api/rides/gas-price?${params.toString()}`, { + method: "GET", + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error( diff --git a/src/BikeTracking.Frontend/src/services/users-api.test.ts b/src/BikeTracking.Frontend/src/services/users-api.test.ts index 6573d59..df7fe8f 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.test.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.test.ts @@ -221,6 +221,44 @@ describe("users-api transport", () => { expect(result.ok).toBe(true); }); + it("saveUserSettings includes gasGrade in payload and response", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + hasSettings: true, + settings: { + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + locationLabel: null, + latitude: null, + longitude: null, + dashboardGallonsAvoidedEnabled: true, + dashboardGoalProgressEnabled: true, + weatherApiKey: null, + eiaGasApiKey: null, + gasGrade: "Premium", + updatedAtUtc: "2026-03-30T10:00:00Z", + }, + }, + 200, + ), + ); + + const result = await saveUserSettings({ gasGrade: "Premium" }); + + expect(fetchMock).toHaveBeenCalledWith( + `${url}/users/me/settings`, + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ gasGrade: "Premium" }), + }), + ); + expect(result.ok).toBe(true); + expect(result.data?.settings.gasGrade).toBe("Premium"); + }); + it("settings requests include auth header when session user exists", async () => { sessionStorage.setItem( "bike_tracking_auth_session", diff --git a/src/BikeTracking.Frontend/src/services/users-api.ts b/src/BikeTracking.Frontend/src/services/users-api.ts index d9af886..0cf5c1e 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.ts @@ -47,6 +47,7 @@ export interface UserSettingsUpsertRequest { dashboardGoalProgressEnabled?: boolean | null; weatherApiKey?: string | null; eiaGasApiKey?: string | null; + gasGrade?: "Regular" | "Premium" | null; } export interface UserSettingsView { @@ -62,6 +63,7 @@ export interface UserSettingsView { updatedAtUtc: string | null; weatherApiKey: string | null; eiaGasApiKey: string | null; + gasGrade: "Regular" | "Premium"; } export interface UserSettingsResponse { diff --git a/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts index e4e573a..4a55259 100644 --- a/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts +++ b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts @@ -30,4 +30,33 @@ test.describe("009-settings e2e", () => { await expect(page.locator("#averageCarMpg")).toHaveValue(""); await expect(page.locator("#yearlyGoalMiles")).toHaveValue(""); }); + + test("saving gas grade preference is reflected in ride-form gas lookup", async ({ + page, + }) => { + const rider = uniqueUser("e2e-settings-gas-grade"); + await createAndLoginUser(page, rider, TEST_PIN); + + await page.goto("/settings"); + await page.locator("#gasGrade").selectOption("Premium"); + await page.getByRole("button", { name: "Save Settings" }).click(); + await expect(page.getByText(/settings saved successfully/i)).toBeVisible(); + + await page.goto("/rides/record"); + const response = await page.waitForResponse((candidate) => + candidate.url().includes("/api/rides/gas-price"), + ); + + const payload = (await response.json()) as { + grade?: string; + isAvailable?: boolean; + pricePerGallon?: number | null; + }; + + expect(payload.grade).toBe("Premium"); + + if (payload.isAvailable && payload.pricePerGallon !== null) { + await expect(page.locator("#gasPrice")).toHaveValue(payload.pricePerGallon.toString()); + } + }); });