diff --git a/cmd/trace-coverage-audit/main.go b/cmd/trace-coverage-audit/main.go index 030a1eb8ee..533f0a45ac 100644 --- a/cmd/trace-coverage-audit/main.go +++ b/cmd/trace-coverage-audit/main.go @@ -183,19 +183,21 @@ var flows = []flow{ }, { Name: "in_api_workers", - Description: "Per-iteration spans for in-API background tickers", + Description: "Spans for in-API background workers", GlobPatterns: []string{ "internal/worker/gas_price_worker.go", "internal/worker/maintenance_worker.go", "internal/worker/unit_drift_validator.go", "internal/app/new.go", + "internal/api/telemetry/telemetry_sessions_charge_geofence_pricing.go", }, - MinSpanFiles: 4, + MinSpanFiles: 5, RequiredFiles: []string{ "internal/worker/gas_price_worker.go", "internal/worker/maintenance_worker.go", "internal/worker/unit_drift_validator.go", "internal/app/new.go", + "internal/api/telemetry/telemetry_sessions_charge_geofence_pricing.go", }, }, { diff --git a/docs/runbooks/phase-44-trace-coverage-audit.md b/docs/runbooks/phase-44-trace-coverage-audit.md index 18f69b6214..61a328d82c 100644 --- a/docs/runbooks/phase-44-trace-coverage-audit.md +++ b/docs/runbooks/phase-44-trace-coverage-audit.md @@ -89,11 +89,12 @@ go run ./cmd/trace-coverage-audit -report docs/runbooks/phase-44-trace-coverage- - Matched files: - `internal/tracing/fsmtracer.go` -## Per-iteration spans for in-API background tickers — `in_api_workers` +## Spans for in-API background workers — `in_api_workers` - Status: **OK** -- Instrumented files matched: 4 (threshold ≥ 4) +- Instrumented files matched: 5 (threshold ≥ 5) - Matched files: + - `internal/api/telemetry/telemetry_sessions_charge_geofence_pricing.go` - `internal/app/new.go` - `internal/worker/gas_price_worker.go` - `internal/worker/maintenance_worker.go` diff --git a/helm/teslasync/files/grafana/dashboards/slo-geofence_pricing_read_availability.json b/helm/teslasync/files/grafana/dashboards/slo-geofence_pricing_read_availability.json new file mode 100644 index 0000000000..446605cb9e --- /dev/null +++ b/helm/teslasync/files/grafana/dashboards/slo-geofence_pricing_read_availability.json @@ -0,0 +1,235 @@ +{ + "uid": "slo-geofence_pricing_read_availability", + "title": "SLO: geofence_pricing_read_availability", + "description": "Charging-place rate history, preview, and activity reads must avoid server errors.", + "tags": [ + "slo", + "owner:platform", + "http", + "geofences", + "pricing" + ], + "schemaVersion": 38, + "version": 1, + "time": { + "from": "now-24h", + "to": "now" + }, + "refresh": "30s", + "panels": [ + { + "id": 1, + "type": "stat", + "title": "SLI (5m)", + "description": "Current good/valid ratio.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "slo:geofence_pricing_read_availability:ratio_rate5m", + "legendFormat": "ratio_5m" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 2, + "type": "stat", + "title": "Objective", + "description": "SLO target.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 6, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "vector(0.995)", + "legendFormat": "objective" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 3, + "type": "stat", + "title": "Error budget remaining (30d)", + "description": "1 - (1 - SLI_30d) / (1 - objective). Negative = budget blown.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 12, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "1 - ((1 - slo:geofence_pricing_read_availability:ratio_rate30d) / (1 - 0.995))", + "legendFormat": "budget_remaining" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 4, + "type": "timeseries", + "title": "SLI over time (5m / 1h / 6h)", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "expr": "slo:geofence_pricing_read_availability:ratio_rate5m", + "legendFormat": "5m" + }, + { + "refId": "B", + "expr": "slo:geofence_pricing_read_availability:ratio_rate1h", + "legendFormat": "1h" + }, + { + "refId": "C", + "expr": "slo:geofence_pricing_read_availability:ratio_rate6h", + "legendFormat": "6h" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 5, + "type": "timeseries", + "title": "Burn rate (1h / 6h)", + "description": "Bad-event ratio multiplied so the y-axis is in 'budgets per hour' units.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 12, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "expr": "(1 - slo:geofence_pricing_read_availability:ratio_rate1h) / (1 - 0.995)", + "legendFormat": "1h" + }, + { + "refId": "B", + "expr": "(1 - slo:geofence_pricing_read_availability:ratio_rate6h) / (1 - 0.995)", + "legendFormat": "6h" + } + ], + "fieldConfig": { + "defaults": { + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 6, + "type": "timeseries", + "title": "Latency (with Tempo exemplars)", + "description": "Histogram of the underlying SLI denominator. Exemplars link to Tempo for trace IDs that touched the latency bucket.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 20, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.99, sum by (le) (rate(teslasync_red_http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p99", + "exemplar": true + } + ], + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": null + } + } + ], + "templating": { + "list": [] + }, + "links": [ + { + "title": "Tempo (traces)", + "type": "link", + "url": "/explore?left=%7B%22datasource%22:%22tempo%22%7D" + } + ], + "annotations": { + "list": [] + } +} diff --git a/helm/teslasync/files/grafana/dashboards/slo-geofence_pricing_write_availability.json b/helm/teslasync/files/grafana/dashboards/slo-geofence_pricing_write_availability.json new file mode 100644 index 0000000000..2b85774c70 --- /dev/null +++ b/helm/teslasync/files/grafana/dashboards/slo-geofence_pricing_write_availability.json @@ -0,0 +1,236 @@ +{ + "uid": "slo-geofence_pricing_write_availability", + "title": "SLO: geofence_pricing_write_availability", + "description": "Charging-place rate creation, apply/backfill, and lifecycle writes must avoid server errors.", + "tags": [ + "slo", + "owner:platform", + "http", + "geofences", + "pricing", + "writes" + ], + "schemaVersion": 38, + "version": 1, + "time": { + "from": "now-24h", + "to": "now" + }, + "refresh": "30s", + "panels": [ + { + "id": 1, + "type": "stat", + "title": "SLI (5m)", + "description": "Current good/valid ratio.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "slo:geofence_pricing_write_availability:ratio_rate5m", + "legendFormat": "ratio_5m" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 2, + "type": "stat", + "title": "Objective", + "description": "SLO target.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 6, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "vector(0.99)", + "legendFormat": "objective" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 3, + "type": "stat", + "title": "Error budget remaining (30d)", + "description": "1 - (1 - SLI_30d) / (1 - objective). Negative = budget blown.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 12, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "1 - ((1 - slo:geofence_pricing_write_availability:ratio_rate30d) / (1 - 0.99))", + "legendFormat": "budget_remaining" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 4, + "type": "timeseries", + "title": "SLI over time (5m / 1h / 6h)", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "expr": "slo:geofence_pricing_write_availability:ratio_rate5m", + "legendFormat": "5m" + }, + { + "refId": "B", + "expr": "slo:geofence_pricing_write_availability:ratio_rate1h", + "legendFormat": "1h" + }, + { + "refId": "C", + "expr": "slo:geofence_pricing_write_availability:ratio_rate6h", + "legendFormat": "6h" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 5, + "type": "timeseries", + "title": "Burn rate (1h / 6h)", + "description": "Bad-event ratio multiplied so the y-axis is in 'budgets per hour' units.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 12, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "expr": "(1 - slo:geofence_pricing_write_availability:ratio_rate1h) / (1 - 0.99)", + "legendFormat": "1h" + }, + { + "refId": "B", + "expr": "(1 - slo:geofence_pricing_write_availability:ratio_rate6h) / (1 - 0.99)", + "legendFormat": "6h" + } + ], + "fieldConfig": { + "defaults": { + "decimals": 3 + }, + "overrides": null + } + }, + { + "id": 6, + "type": "timeseries", + "title": "Latency (with Tempo exemplars)", + "description": "Histogram of the underlying SLI denominator. Exemplars link to Tempo for trace IDs that touched the latency bucket.", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 20, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.99, sum by (le) (rate(teslasync_red_http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p99", + "exemplar": true + } + ], + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": null + } + } + ], + "templating": { + "list": [] + }, + "links": [ + { + "title": "Tempo (traces)", + "type": "link", + "url": "/explore?left=%7B%22datasource%22:%22tempo%22%7D" + } + ], + "annotations": { + "list": [] + } +} diff --git a/helm/teslasync/files/grafana/dashboards/slo-overview.json b/helm/teslasync/files/grafana/dashboards/slo-overview.json index 0578b84c52..02dd3118db 100644 --- a/helm/teslasync/files/grafana/dashboards/slo-overview.json +++ b/helm/teslasync/files/grafana/dashboards/slo-overview.json @@ -357,6 +357,92 @@ "values": false } } + }, + { + "id": 9, + "type": "stat", + "title": "geofence_pricing_read_availability", + "description": "Objective: 99.5%; owner: platform", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 8, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "slo:geofence_pricing_read_availability:ratio_rate1h", + "legendFormat": "1h" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + }, + "options": { + "colorMode": "value", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "id": 10, + "type": "stat", + "title": "geofence_pricing_write_availability", + "description": "Objective: 99%; owner: platform", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 6, + "y": 8, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "slo:geofence_pricing_write_availability:ratio_rate1h", + "legendFormat": "1h" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "decimals": 3 + }, + "overrides": null + }, + "options": { + "colorMode": "value", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } } ], "templating": { diff --git a/helm/teslasync/files/prometheus/alerting-rules.yaml b/helm/teslasync/files/prometheus/alerting-rules.yaml index c09df94ed0..a79cd41e90 100644 --- a/helm/teslasync/files/prometheus/alerting-rules.yaml +++ b/helm/teslasync/files/prometheus/alerting-rules.yaml @@ -242,3 +242,63 @@ groups: summary: "SLO frontend_lcp slow burn: error budget consumed at >=6x sustainable rate over the last 6h" description: "Error budget for SLO \"frontend_lcp\" (objective 90%, owner frontend) is burning at >=6x the sustainable rate on both the 6h and 30m windows." runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md" + - name: slo_geofence_pricing_read_availability_burn + rules: + - alert: SLOGeofencePricingReadAvailabilityFastBurn + expr: "(1 - slo:geofence_pricing_read_availability:ratio_rate1h) > 0.072000 and (1 - slo:geofence_pricing_read_availability:ratio_rate5m) > 0.072000" + for: 2m + labels: + severity: page + slo: "geofence_pricing_read_availability" + owner: "platform" + burn_rate: "14.4" + long_window: "1h" + short_window: "5m" + annotations: + summary: "SLO geofence_pricing_read_availability fast burn: error budget consumed at >=14.4x sustainable rate over the last 1h" + description: "Error budget for SLO \"geofence_pricing_read_availability\" (objective 99.5%, owner platform) is burning at >=14.4x the sustainable rate on both the 1h and 5m windows." + runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md" + - alert: SLOGeofencePricingReadAvailabilitySlowBurn + expr: "(1 - slo:geofence_pricing_read_availability:ratio_rate6h) > 0.030000 and (1 - (sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\",status_class!=\"5xx\"}[30m]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\"}[30m]))), 1)) > 0.030000" + for: 15m + labels: + severity: ticket + slo: "geofence_pricing_read_availability" + owner: "platform" + burn_rate: "6" + long_window: "6h" + short_window: "30m" + annotations: + summary: "SLO geofence_pricing_read_availability slow burn: error budget consumed at >=6x sustainable rate over the last 6h" + description: "Error budget for SLO \"geofence_pricing_read_availability\" (objective 99.5%, owner platform) is burning at >=6x the sustainable rate on both the 6h and 30m windows." + runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md" + - name: slo_geofence_pricing_write_availability_burn + rules: + - alert: SLOGeofencePricingWriteAvailabilityFastBurn + expr: "(1 - slo:geofence_pricing_write_availability:ratio_rate1h) > 0.144000 and (1 - slo:geofence_pricing_write_availability:ratio_rate5m) > 0.144000" + for: 2m + labels: + severity: page + slo: "geofence_pricing_write_availability" + owner: "platform" + burn_rate: "14.4" + long_window: "1h" + short_window: "5m" + annotations: + summary: "SLO geofence_pricing_write_availability fast burn: error budget consumed at >=14.4x sustainable rate over the last 1h" + description: "Error budget for SLO \"geofence_pricing_write_availability\" (objective 99%, owner platform) is burning at >=14.4x the sustainable rate on both the 1h and 5m windows." + runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md" + - alert: SLOGeofencePricingWriteAvailabilitySlowBurn + expr: "(1 - slo:geofence_pricing_write_availability:ratio_rate6h) > 0.060000 and (1 - (sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\",status_class!=\"5xx\"}[30m]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\"}[30m]))), 1)) > 0.060000" + for: 15m + labels: + severity: ticket + slo: "geofence_pricing_write_availability" + owner: "platform" + burn_rate: "6" + long_window: "6h" + short_window: "30m" + annotations: + summary: "SLO geofence_pricing_write_availability slow burn: error budget consumed at >=6x sustainable rate over the last 6h" + description: "Error budget for SLO \"geofence_pricing_write_availability\" (objective 99%, owner platform) is burning at >=6x the sustainable rate on both the 6h and 30m windows." + runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md" diff --git a/helm/teslasync/files/prometheus/recording-rules.yaml b/helm/teslasync/files/prometheus/recording-rules.yaml index 3c7751ca2f..16d9931a92 100644 --- a/helm/teslasync/files/prometheus/recording-rules.yaml +++ b/helm/teslasync/files/prometheus/recording-rules.yaml @@ -249,3 +249,65 @@ groups: window: "30d" owner: "frontend" objective: "90" + - name: slo_geofence_pricing_read_availability + interval: 30s + rules: + - record: slo:geofence_pricing_read_availability:ratio_rate5m + expr: "(sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\",status_class!=\"5xx\"}[5m]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\"}[5m]))), 1)" + labels: + slo: "geofence_pricing_read_availability" + window: "5m" + owner: "platform" + objective: "99.5" + - record: slo:geofence_pricing_read_availability:ratio_rate1h + expr: "(sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\",status_class!=\"5xx\"}[1h]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\"}[1h]))), 1)" + labels: + slo: "geofence_pricing_read_availability" + window: "1h" + owner: "platform" + objective: "99.5" + - record: slo:geofence_pricing_read_availability:ratio_rate6h + expr: "(sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\",status_class!=\"5xx\"}[6h]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\"}[6h]))), 1)" + labels: + slo: "geofence_pricing_read_availability" + window: "6h" + owner: "platform" + objective: "99.5" + - record: slo:geofence_pricing_read_availability:ratio_rate30d + expr: "(sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\",status_class!=\"5xx\"}[30d]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\"}[30d]))), 1)" + labels: + slo: "geofence_pricing_read_availability" + window: "30d" + owner: "platform" + objective: "99.5" + - name: slo_geofence_pricing_write_availability + interval: 30s + rules: + - record: slo:geofence_pricing_write_availability:ratio_rate5m + expr: "(sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\",status_class!=\"5xx\"}[5m]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\"}[5m]))), 1)" + labels: + slo: "geofence_pricing_write_availability" + window: "5m" + owner: "platform" + objective: "99" + - record: slo:geofence_pricing_write_availability:ratio_rate1h + expr: "(sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\",status_class!=\"5xx\"}[1h]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\"}[1h]))), 1)" + labels: + slo: "geofence_pricing_write_availability" + window: "1h" + owner: "platform" + objective: "99" + - record: slo:geofence_pricing_write_availability:ratio_rate6h + expr: "(sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\",status_class!=\"5xx\"}[6h]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\"}[6h]))), 1)" + labels: + slo: "geofence_pricing_write_availability" + window: "6h" + owner: "platform" + objective: "99" + - record: slo:geofence_pricing_write_availability:ratio_rate30d + expr: "(sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\",status_class!=\"5xx\"}[30d]))) / clamp_min((sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\"}[30d]))), 1)" + labels: + slo: "geofence_pricing_write_availability" + window: "30d" + owner: "platform" + objective: "99" diff --git a/internal/adapter/postgres/queries/charging.go b/internal/adapter/postgres/queries/charging.go index 66f9bf0603..c3dcbc2a1d 100644 --- a/internal/adapter/postgres/queries/charging.go +++ b/internal/adapter/postgres/queries/charging.go @@ -58,12 +58,13 @@ const ( INSERT INTO charging_sessions ( id, vehicle_id, charger_type, start_soc_pct, end_soc_pct, total_energy_added_wh, peak_power_w, cost_decimal, - started_at, ended_at + cost_source, started_at, ended_at ) VALUES ( $1::bigint, $2::bigint, $3, $4::double precision, $5::double precision, $6::double precision, $7::double precision, $8::numeric / 100.0, + 'unknown', $9, $10 ) ON CONFLICT (id) DO UPDATE SET @@ -72,6 +73,11 @@ const ( end_soc_pct = EXCLUDED.end_soc_pct, total_energy_added_wh = EXCLUDED.total_energy_added_wh, peak_power_w = EXCLUDED.peak_power_w, - cost_decimal = EXCLUDED.cost_decimal, + cost_decimal = CASE + WHEN charging_sessions.cost_source IS NULL + THEN EXCLUDED.cost_decimal + ELSE charging_sessions.cost_decimal + END, + cost_source = COALESCE(charging_sessions.cost_source, EXCLUDED.cost_source), ended_at = EXCLUDED.ended_at` ) diff --git a/internal/api/api_call_log_middleware.go b/internal/api/api_call_log_middleware.go index 72aec3e8e2..3fa458f30a 100644 --- a/internal/api/api_call_log_middleware.go +++ b/internal/api/api_call_log_middleware.go @@ -61,6 +61,15 @@ const ( // that may carry secret material; matching values are replaced with REDACTED. var redactKeyPattern = regexp.MustCompile(`(?i)token|key|secret|password|cookie`) +// opaqueVehicleManagementPathPattern identifies pricing and state-changing +// payer requests whose undocumented payloads and responses must never be +// captured. An optional trailing slash is covered so a router redirect or 404 +// cannot persist a submitted opaque object. The local vehicle id remains +// available to the handler's structured operation/status log. +var opaqueVehicleManagementPathPattern = regexp.MustCompile( + `^/api/v1/(?:tesla/vehicle-pricing|vehicles/[^/]+/enterprise-payer)/?$`, +) + // APICallLogger is the writer port the middleware depends on. // // Deprecated: use apilog.Logger. Will be removed in phase-48. @@ -134,7 +143,7 @@ func DefaultAPILogSkip(path string) bool { "/api/v1/system/status": return true } - return false + return opaqueVehicleManagementPathPattern.MatchString(path) } // APICallLogMiddleware enqueues one sanitized log entry per non-skipped request. diff --git a/internal/api/api_call_log_middleware_test.go b/internal/api/api_call_log_middleware_test.go index 4571c736cc..53c0e40c69 100644 --- a/internal/api/api_call_log_middleware_test.go +++ b/internal/api/api_call_log_middleware_test.go @@ -149,6 +149,8 @@ func newTestRouterWithLogger(t *testing.T, store APICallLogger, captureBodies bo r.Get("/events", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) r.Get("/sse-token", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) r.Get("/system/status", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + r.Post("/tesla/vehicle-pricing", echoBody) + r.Post("/vehicles/{vehicleID}/enterprise-payer", echoBody) // Recorded paths r.Get("/vehicles", func(w http.ResponseWriter, r *http.Request) { @@ -349,6 +351,7 @@ func TestT04_SkippedPaths_NoRowsEnqueued(t *testing.T) { "/api/v1/sse-token", "/api/v1/system/status", } + for _, p := range skipPaths { resp, err := http.Get(srv.URL + p) if err != nil { @@ -361,6 +364,39 @@ func TestT04_SkippedPaths_NoRowsEnqueued(t *testing.T) { } } +func TestOpaqueVehicleManagementBodiesAreNeverCaptured(t *testing.T) { + store := &fakeAPILogStore{} + srv := httptest.NewServer(newTestRouterWithLogger(t, store, true)) + defer srv.Close() + + tests := []string{ + "/api/v1/tesla/vehicle-pricing", + "/api/v1/tesla/vehicle-pricing/", + "/api/v1/vehicles/42/enterprise-payer", + "/api/v1/vehicles/42/enterprise-payer/", + } + for _, path := range tests { + req, err := http.NewRequest( + http.MethodPost, + srv.URL+path, + strings.NewReader(`{"payload":{"possible_pii":"must-not-persist"}}`), + ) + if err != nil { + t.Fatalf("build request for %s: %v", path, err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST %s: %v", path, err) + } + resp.Body.Close() + } + + if got := len(store.Entries()); got != 0 { + t.Fatalf("opaque management requests enqueued %d audit row(s), want 0", got) + } +} + // T05: redaction of Authorization, Cookie, and ?api_key= query param. // Non-secret query params are kept; visible body-capture tokens never // appear in the stored body. diff --git a/internal/api/apperror/apperror.go b/internal/api/apperror/apperror.go index ea914bbc06..46737d5eb0 100644 --- a/internal/api/apperror/apperror.go +++ b/internal/api/apperror/apperror.go @@ -129,6 +129,15 @@ var ( ErrGeofenceNotFound = &AppError{"GEOFENCE_NOT_FOUND", "geofence not found", http.StatusNotFound, ErrCatGeofence} ErrGeofenceInvalidCoords = &AppError{"GEOFENCE_INVALID_COORDINATES", "invalid geofence coordinates", http.StatusBadRequest, ErrCatGeofence} ErrGeofenceInvalidRadius = &AppError{"GEOFENCE_INVALID_RADIUS", "geofence radius must be positive", http.StatusBadRequest, ErrCatGeofence} + + // Charging-place pricing (migration 000228_geofence_charging_place_pricing). + ErrGeofenceRateNotFound = &AppError{"GEOFENCE_RATE_NOT_FOUND", "geofence rate not found", http.StatusNotFound, ErrCatGeofence} + ErrGeofenceRateConflict = &AppError{"GEOFENCE_RATE_CONFLICT", "rate interval overlaps an existing rate for this geofence", http.StatusConflict, ErrCatGeofence} + ErrGeofenceRateInvalid = &AppError{"GEOFENCE_RATE_INVALID", "invalid geofence rate", http.StatusBadRequest, ErrCatGeofence} + ErrGeofenceRateInUse = &AppError{"GEOFENCE_RATE_IN_USE", "rate is already referenced by charging history", http.StatusConflict, ErrCatGeofence} + ErrGeofenceRateImmutable = &AppError{"GEOFENCE_RATE_IMMUTABLE", "effective rates are immutable; add a new dated rate instead", http.StatusConflict, ErrCatGeofence} + ErrGeofenceArchived = &AppError{"GEOFENCE_ARCHIVED", "geofence is archived", http.StatusConflict, ErrCatGeofence} + ErrGeofenceHasHistory = &AppError{"GEOFENCE_HAS_HISTORY", "geofence has associated charging/drive history; archive instead of deleting", http.StatusConflict, ErrCatGeofence} ) // --- Commands --- @@ -198,6 +207,8 @@ func ErrorCatalog() []*AppError { ErrInvalidInput, ErrInvalidJSON, ErrMissingField, ErrInvalidRange, ErrInvalidID, ErrPayloadTooLarge, ErrGeofenceNotFound, ErrGeofenceInvalidCoords, ErrGeofenceInvalidRadius, + ErrGeofenceRateNotFound, ErrGeofenceRateConflict, ErrGeofenceRateInvalid, + ErrGeofenceRateInUse, ErrGeofenceRateImmutable, ErrGeofenceArchived, ErrGeofenceHasHistory, ErrCommandNotSupported, ErrCommandFailed, ErrCommandTimeout, ErrBackupConfigNotFound, ErrBackupRunNotFound, ErrBackupFailed, ErrBackupStorageError, ErrRestoreFailed, diff --git a/internal/api/chargetelem/handler.go b/internal/api/chargetelem/handler.go index bf2dc7d4dc..f5e0dc9beb 100644 --- a/internal/api/chargetelem/handler.go +++ b/internal/api/chargetelem/handler.go @@ -27,8 +27,9 @@ type ChargingTelemetryHandler struct { live signal.LiveStateReader } -// Signal → JSON field mappings for charging telemetry projection. -// Field names match the frontend ChargingTelemetry interface in api/types.ts. +// Signal → JSON field mappings for charging telemetry projection. Temporary +// DC fields are merged into the frontend ChargingTelemetry shape before the +// response is written. var chargingTelemetryMappings = []signal.FieldMapping{ {Signal: "ChargerVoltage", Field: "charger_voltage"}, {Signal: "ChargerActualCurrent", Field: "charger_actual_current"}, @@ -38,11 +39,12 @@ var chargingTelemetryMappings = []signal.FieldMapping{ {Signal: "Soc", Field: "soc"}, {Signal: "BatteryLevel", Field: "battery_level"}, {Signal: "ACChargingEnergyIn", Field: "charge_energy_added_wh"}, - {Signal: "DCChargingEnergyIn", Field: "energy_added_dc"}, + {Signal: "DCChargingEnergyIn", Field: "dc_charge_energy_added_wh"}, {Signal: "TimeToFullCharge", Field: "time_to_full_charge"}, {Signal: "BrickVoltageMax", Field: "brick_voltage_max"}, {Signal: "BrickVoltageMin", Field: "brick_voltage_min"}, {Signal: "ACChargingPower", Field: "charger_power_w"}, + {Signal: "DCChargingPower", Field: "dc_charger_power_w"}, {Signal: "ChargerPhases", Field: "charger_phases"}, {Signal: "IdealBatteryRange", Field: "battery_range_mi"}, {Signal: "ChargeState", Field: "charging_state"}, @@ -80,6 +82,9 @@ func (h *ChargingTelemetryHandler) List(w http.ResponseWriter, r *http.Request) return } rows := timelineRowsToFlat(timelineRows) + for _, row := range rows { + mergeDCChargingValues(row) + } httpx.WriteJSON(w, http.StatusOK, rows) } @@ -105,13 +110,22 @@ func (h *ChargingTelemetryHandler) Latest(w http.ResponseWriter, r *http.Request result[m.Field] = v } } - // DC fast-charging reports DCChargingPower; override the AC field only when positive. - if dcVal, ok := snap["DCChargingPower"]; ok { - if dc, dcOk := signal.Float64(dcVal); dcOk && dc > 0 { - result["charger_power_w"] = dcVal + mergeDCChargingValues(result) + httpx.WriteJSON(w, http.StatusOK, result) +} + +func mergeDCChargingValues(row map[string]interface{}) { + preferPositiveDCValue(row, "charger_power_w", "dc_charger_power_w") + preferPositiveDCValue(row, "charge_energy_added_wh", "dc_charge_energy_added_wh") +} + +func preferPositiveDCValue(row map[string]interface{}, canonicalKey, dcKey string) { + if value, ok := row[dcKey]; ok { + if numeric, numericOK := signal.Float64(value); numericOK && numeric > 0 { + row[canonicalKey] = value } } - httpx.WriteJSON(w, http.StatusOK, result) + delete(row, dcKey) } // timelineRowsToFlat preserves the legacy flat-pivot response shape. diff --git a/internal/api/chargetelem/handler_test.go b/internal/api/chargetelem/handler_test.go index 9e158a6e4d..bd6ef83137 100644 --- a/internal/api/chargetelem/handler_test.go +++ b/internal/api/chargetelem/handler_test.go @@ -27,27 +27,30 @@ func newChargingTelemetryRequest(vehicleID string, target string) *http.Request // the charging-detail page depends on consecutive emissions surviving even // when their projected fields are identical to the previous row — collapsing // would drop "still 200V, still 65%" tuples and break the time-series -// rendering. Also asserts the canonical 16-mapping field set is forwarded -// so the wire shape (charger_voltage, charger_power_kw, ...) stays stable. +// rendering. Also asserts the canonical mapping set is forwarded so the wire +// shape (charger_voltage, charger_power_w, ...) stays stable. func TestChargingTelemetry_Chart_NoCollapse(t *testing.T) { t0 := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC) rows := []signal.TimelineRow{ {Timestamp: t0, Fields: map[string]signal.SignalValue{ - "charger_voltage": 240.0, - "charger_actual_current": 32.0, - "charger_power_kw": 7.7, - "battery_level": 55.0, + "charger_voltage": 240.0, + "charger_actual_current": 32.0, + "charger_power_w": 7700.0, + "dc_charger_power_w": 150000.0, + "charge_energy_added_wh": 10000.0, + "dc_charge_energy_added_wh": 12000.0, + "battery_level": 55.0, }}, {Timestamp: t0.Add(30 * time.Second), Fields: map[string]signal.SignalValue{ "charger_voltage": 240.0, "charger_actual_current": 32.0, - "charger_power_kw": 7.7, + "charger_power_w": 7700.0, "battery_level": 55.0, }}, {Timestamp: t0.Add(60 * time.Second), Fields: map[string]signal.SignalValue{ "charger_voltage": 240.0, "charger_actual_current": 32.0, - "charger_power_kw": 7.7, + "charger_power_w": 7700.0, "battery_level": 56.0, }}, } @@ -86,6 +89,15 @@ func TestChargingTelemetry_Chart_NoCollapse(t *testing.T) { t.Fatalf("row[%d] missing ts key; got %v", i, row) } } + if got[0]["charger_power_w"] != 150000.0 || got[0]["charge_energy_added_wh"] != 12000.0 { + t.Fatalf("DC values were not merged into canonical fields: %v", got[0]) + } + if _, ok := got[0]["dc_charger_power_w"]; ok { + t.Fatalf("temporary DC power field leaked into response: %v", got[0]) + } + if _, ok := got[0]["dc_charge_energy_added_wh"]; ok { + t.Fatalf("temporary DC energy field leaked into response: %v", got[0]) + } } // TestChargingTelemetry_Latest_UsesNow verifies that Latest derives the @@ -94,7 +106,7 @@ func TestChargingTelemetry_Chart_NoCollapse(t *testing.T) { // signal under its JSON field name; the special DCChargingPower override // stays in this handler (DC fast-charge sessions report DCChargingPower // instead of ACChargingPower, and the frontend exposes a single -// charger_power_kw field). +// charger_power_w field). func TestChargingTelemetry_Latest_UsesNow(t *testing.T) { var gotAt time.Time var gotVehicleID int64 @@ -107,8 +119,10 @@ func TestChargingTelemetry_Latest_UsesNow(t *testing.T) { return signal.State{ "ChargerVoltage": 240.0, "ChargerActualCurrent": 32.0, - "ACChargingPower": 7.7, - "DCChargingPower": 150.0, // active DC charge — must override AC + "ACChargingPower": 7700.0, + "DCChargingPower": 150000.0, // active DC charge — must override AC + "ACChargingEnergyIn": 10000.0, + "DCChargingEnergyIn": 12000.0, "BatteryLevel": 65.0, "ChargeState": "Charging", "IdealBatteryRange": 250.0, @@ -145,9 +159,12 @@ func TestChargingTelemetry_Latest_UsesNow(t *testing.T) { if v, ok := got["charging_state"].(string); !ok || v != "Charging" { t.Fatalf("charging_state = %v, want Charging", got["charging_state"]) } - // DCChargingPower=150 must override AC power and be returned under the SI key. - if v, ok := got["charger_power_w"].(float64); !ok || v != 150.0 { - t.Fatalf("charger_power_w = %v, want 150 (DC override active)", got["charger_power_w"]) + // DCChargingPower=150000 W must override AC power under the SI key. + if v, ok := got["charger_power_w"].(float64); !ok || v != 150000.0 { + t.Fatalf("charger_power_w = %v, want 150000 (DC override active)", got["charger_power_w"]) + } + if v, ok := got["charge_energy_added_wh"].(float64); !ok || v != 12000.0 { + t.Fatalf("charge_energy_added_wh = %v, want 12000 (DC override active)", got["charge_energy_added_wh"]) } } diff --git a/internal/api/charging/handler.go b/internal/api/charging/handler.go index 22a8e1b159..8931d11d33 100644 --- a/internal/api/charging/handler.go +++ b/internal/api/charging/handler.go @@ -68,15 +68,16 @@ func (h *ChargingHandler) WithForwardAuthHeader(name string) *ChargingHandler { // chargeTelemetryFieldMappings projects the signal_log change feed into the // legacy ChargeTelemetryReading JSON shape. Field names match the old // pivot-mapping signal/field pairs so the wire contract is unchanged. -// AC/DC power is merged into a canonical "power_kw" by the +// AC/DC power is merged into the existing "power_kw" chart field by the // TelemetryReadings handler post-processing. var chargeTelemetryFieldMappings = []signal.FieldMapping{ {Signal: "BatteryLevel", Field: "battery_level"}, {Signal: "ChargerVoltage", Field: "voltage"}, {Signal: "ChargerActualCurrent", Field: "current_amps"}, {Signal: "ACChargingPower", Field: "power_kw"}, - {Signal: "DCChargingPower", Field: "dc_power_kw"}, + {Signal: "DCChargingPower", Field: "dc_power_w"}, {Signal: "ACChargingEnergyIn", Field: "energy_added"}, + {Signal: "DCChargingEnergyIn", Field: "dc_energy_wh"}, {Signal: "ChargeRateMilePerHour", Field: "range_added_meters_per_hour"}, {Signal: "BatteryHeaterOn", Field: "battery_heater_on"}, {Signal: "InsideTemp", Field: "inside_temp"}, @@ -206,14 +207,20 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin } } - startEnergy, startOk := signalFloat(startSnap, "ACChargingEnergyIn") - currentEnergy, currentOk := signalFloat(currentSnap, "ACChargingEnergyIn") - if startOk && currentOk && currentEnergy > startEnergy { - delta := safeFloat(currentEnergy - startEnergy) - session.TotalEnergyAddedWh = &delta + for _, field := range []string{"DCChargingEnergyIn", "ACChargingEnergyIn"} { + startEnergy, startOK := signalFloat(startSnap, field) + currentEnergy, currentOK := signalFloat(currentSnap, field) + if startOK && currentOK && currentEnergy > startEnergy { + delta := safeFloat(currentEnergy - startEnergy) + session.TotalEnergyAddedWh = &delta + break + } } - if power, ok := signalFloat(currentSnap, "ACChargingPower"); ok { + if power, ok := signalFloat(currentSnap, "DCChargingPower"); ok && power > 0 { + v := safeFloat(power) + session.PeakPowerW = &v + } else if power, ok := signalFloat(currentSnap, "ACChargingPower"); ok { v := safeFloat(power) session.PeakPowerW = &v } @@ -269,18 +276,27 @@ func (h *ChargingHandler) TelemetryReadings(w http.ResponseWriter, r *http.Reque return } rows := timelineRowsToFlat(timelineRows) - // Merge AC/DC power into a canonical power_kw field. - // DC fast-charging sessions report DCChargingPower, not ACChargingPower. - // Per ADR-002: if neither is present, leave power_kw as nil (not zero). + // The signal change feed is canonical W/Wh. Preserve this endpoint's + // established legacy chart contract (power_kw / energy_added in kW/kWh) + // strictly at the HTTP boundary; session summaries remain Wh/W. for _, row := range rows { - ac, acOk := signal.Float64(row["power_kw"]) - dc, dcOk := signal.Float64(row["dc_power_kw"]) - if dcOk && dc > 0 { - row["power_kw"] = dc - } else if !acOk || ac == 0 { - // Neither AC nor DC has a positive value — leave power_kw as-is (nil) + acPowerW, acPowerOK := signal.Float64(row["power_kw"]) + dcPowerW, dcPowerOK := signal.Float64(row["dc_power_w"]) + if dcPowerOK && dcPowerW > 0 { + row["power_kw"] = safeFloat(dcPowerW / 1000.0) + } else if acPowerOK { + row["power_kw"] = safeFloat(acPowerW / 1000.0) + } + + acEnergyWh, acEnergyOK := signal.Float64(row["energy_added"]) + dcEnergyWh, dcEnergyOK := signal.Float64(row["dc_energy_wh"]) + if dcEnergyOK && dcEnergyWh > 0 { + row["energy_added"] = safeFloat(dcEnergyWh / 1000.0) + } else if acEnergyOK { + row["energy_added"] = safeFloat(acEnergyWh / 1000.0) } - delete(row, "dc_power_kw") + delete(row, "dc_power_w") + delete(row, "dc_energy_wh") } // Rename "ts" → "created_at" to match old ChargeTelemetryReading JSON shape for _, row := range rows { diff --git a/internal/api/charging/handler_test.go b/internal/api/charging/handler_test.go index ca67b389c8..e2fe9d81f6 100644 --- a/internal/api/charging/handler_test.go +++ b/internal/api/charging/handler_test.go @@ -186,6 +186,46 @@ func TestChargingHandler_Latest_UsesNowSnapshot(t *testing.T) { } } +func TestChargingHandler_LiveDCSessionUsesCanonicalWhAndW(t *testing.T) { + startTs := time.Date(2026, 8, 9, 9, 30, 0, 0, time.UTC) + session := inProgressChargingSession(11, 42, startTs) + fake := &fakeStateReader{ + stateFn: func(_ context.Context, _ int64, at time.Time) (signal.State, error) { + if at.Equal(startTs) { + return signal.State{ + "DCChargingEnergyIn": 100000.0, + "BatteryLevel": 40.0, + }, nil + } + return signal.State{ + "DCChargingEnergyIn": 112000.0, + "DCChargingPower": 150000.0, + "ACChargingPower": 7200.0, + "BatteryLevel": 55.0, + }, nil + }, + } + charging := &fakeChargingByIDFetcher{session: session} + h := &ChargingHandler{state: fake, live: newTestLiveStateReader(fake), charging: charging} + + rec := httptest.NewRecorder() + h.Get(rec, newChargingRequest(t, "11", "")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var got chargingmodel.ChargingSession + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.TotalEnergyAddedWh == nil || *got.TotalEnergyAddedWh != 12000 { + t.Fatalf("total_energy_added_wh = %v, want 12000", got.TotalEnergyAddedWh) + } + if got.PeakPowerW == nil || *got.PeakPowerW != 150000 { + t.Fatalf("peak_power_w = %v, want 150000", got.PeakPowerW) + } +} + // TestChargingHandler_Telemetry_ChartMode locks in the chart-mode contract: // TelemetryReadings MUST call Timeline with an empty CollapseBy slice so // every change-feed emission becomes one row (forward-folded values appear @@ -223,6 +263,46 @@ func TestChargingHandler_Telemetry_ChartMode(t *testing.T) { } } +func TestChargingHandler_Telemetry_ConvertsCanonicalSIToLegacyChartUnits(t *testing.T) { + t0 := time.Date(2026, 8, 9, 10, 0, 0, 0, time.UTC) + t1 := t0.Add(15 * time.Minute) + fake := &fakeStateReader{ + timelineFn: func(_ context.Context, _ int64, _ []signal.FieldMapping, _, _ time.Time, _ signal.TimelineOptions) ([]signal.TimelineRow, error) { + return []signal.TimelineRow{{ + Timestamp: t0, + Fields: map[string]signal.SignalValue{ + "power_kw": 7200.0, + "dc_power_w": 0.0, + "energy_added": 15089.164733886719, + "battery_level": 65.0, + }, + }}, nil + }, + } + charging := &fakeChargingByIDFetcher{session: completedChargingSession(7, 42, t0, t1)} + h := &ChargingHandler{state: fake, live: newTestLiveStateReader(fake), charging: charging} + + rec := httptest.NewRecorder() + h.TelemetryReadings(rec, newChargingRequest(t, "7", "/charging/7/telemetry")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var rows []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1", len(rows)) + } + if got := rows[0]["power_kw"]; got != 7.2 { + t.Fatalf("power_kw = %v, want 7.2", got) + } + if got := rows[0]["energy_added"]; got != 15.089164733886718 { + t.Fatalf("energy_added = %v, want 15.089164733886718", got) + } +} + // TestChargingHandler_Telemetry_PropagatesError verifies that a Timeline // transport error (e.g. pgx connection drop) becomes a 500 to the client. // The legacy handler also returned 500 here; this test locks the contract diff --git a/internal/api/datarepair/handler.go b/internal/api/datarepair/handler.go index 89611d4c98..5b7ee30494 100644 --- a/internal/api/datarepair/handler.go +++ b/internal/api/datarepair/handler.go @@ -13,6 +13,7 @@ import ( drivedb "github.com/ev-dev-labs/teslasync/internal/database/drive" chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging" drivemodel "github.com/ev-dev-labs/teslasync/internal/models/drive" + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" "github.com/rs/zerolog/log" ) @@ -71,6 +72,32 @@ func (h *DataRepairHandler) now() time.Time { return time.Now().UTC() } +// applyManualCostProvenance prevents the public repair endpoint from forging +// system-owned pricing provenance. A user-supplied cost becomes a manual +// actual and no longer points at the tariff it may have replaced. Clearing a +// cost clears its provenance so a later explicit tariff apply can price it. +func applyManualCostProvenance(existing *chargingmodel.ChargingSession, patch map[string]interface{}) { + delete(patch, "cost_source") + delete(patch, "rate_id") + delete(patch, "geofence_id") + + if cost, supplied := patch["cost_decimal"]; supplied { + patch["rate_id"] = nil + if cost == nil { + patch["cost_currency"] = nil + patch["cost_source"] = nil + return + } + patch["cost_source"] = systemmodel.CostSourceManual + return + } + if _, supplied := patch["cost_currency"]; supplied && + existing != nil && existing.CostDecimal != nil { + patch["rate_id"] = nil + patch["cost_source"] = systemmodel.CostSourceManual + } +} + // StaleSessionsResponse contains charging sessions and drives that are still open. type StaleSessionsResponse struct { StaleCharging []*chargingmodel.ChargingSession `json:"stale_charging"` @@ -134,6 +161,7 @@ func (h *DataRepairHandler) UpdateCharging(w http.ResponseWriter, r *http.Reques httpx.WriteError(w, http.StatusBadRequest, "invalid JSON body") return } + applyManualCostProvenance(existing, patch) if err := h.chargingRepo.PartialUpdate(ctx, id, patch); err != nil { log.Error().Err(err).Int64("id", id).Msg("failed to update charging session") diff --git a/internal/api/datarepair/handler_test.go b/internal/api/datarepair/handler_test.go index 2d12b27563..7f0ed48610 100644 --- a/internal/api/datarepair/handler_test.go +++ b/internal/api/datarepair/handler_test.go @@ -255,7 +255,7 @@ func TestGetStaleSessions_HappyPath(t *testing.T) { } drive := &fakeDriveRepo{ getStaleFn: func(context.Context, time.Time) ([]*drivemodel.Drive, error) { - return []*drivemodel.Drive{sampleDrive(21, testNow.Add(-48 * time.Hour))}, nil + return []*drivemodel.Drive{sampleDrive(21, testNow.Add(-48*time.Hour))}, nil }, } h := newTestHandler(charging, drive) @@ -414,6 +414,56 @@ func TestUpdateCharging_HappyPath(t *testing.T) { } } +func TestUpdateCharging_CostProvenance(t *testing.T) { + t.Parallel() + + t.Run("manual cost overrides system provenance", func(t *testing.T) { + fake := &fakeChargingRepo{ + getByIDFn: func(_ context.Context, id int64) (*chargingmodel.ChargingSession, error) { + return sampleCharging(id), nil + }, + } + h := newTestHandler(fake, &fakeDriveRepo{}) + + rec := doReq(t, h, http.MethodPut, "/data-repair/charging/42", + strings.NewReader(`{"cost_decimal":12.5,"cost_currency":"USD","cost_source":"tesla_actual","rate_id":9,"geofence_id":8}`)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200. body=%s", rec.Code, rec.Body.String()) + } + if got := fake.partialFields["cost_source"]; got != "manual" { + t.Fatalf("cost_source = %v, want manual", got) + } + if got, ok := fake.partialFields["rate_id"]; !ok || got != nil { + t.Fatalf("rate_id = %v (present=%v), want explicit nil", got, ok) + } + if _, ok := fake.partialFields["geofence_id"]; ok { + t.Fatalf("public update must not accept geofence_id: %v", fake.partialFields) + } + }) + + t.Run("clearing cost makes it eligible for a later tariff apply", func(t *testing.T) { + fake := &fakeChargingRepo{ + getByIDFn: func(_ context.Context, id int64) (*chargingmodel.ChargingSession, error) { + return sampleCharging(id), nil + }, + } + h := newTestHandler(fake, &fakeDriveRepo{}) + + rec := doReq(t, h, http.MethodPut, "/data-repair/charging/42", + strings.NewReader(`{"cost_decimal":null,"cost_source":"manual","rate_id":9}`)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200. body=%s", rec.Code, rec.Body.String()) + } + for _, key := range []string{"cost_decimal", "cost_currency", "cost_source", "rate_id"} { + if got, ok := fake.partialFields[key]; !ok || got != nil { + t.Errorf("%s = %v (present=%v), want explicit nil", key, got, ok) + } + } + }) +} + func TestUpdateCharging_Errors(t *testing.T) { t.Parallel() diff --git a/internal/api/energyflow/handler.go b/internal/api/energyflow/handler.go index 0a5f4a6fd5..d895a7aa19 100644 --- a/internal/api/energyflow/handler.go +++ b/internal/api/energyflow/handler.go @@ -57,10 +57,14 @@ func (h *EnergyFlowHandler) Get(w http.ResponseWriter, r *http.Request) { } if v, ok := signal.Float64(snap["DCChargingPower"]); ok { - dcPower = &v + // Preserve this endpoint's established kW response contract while + // live signal state remains canonical W. + kw := v / 1000.0 + dcPower = &kw } if v, ok := signal.Float64(snap["ACChargingPower"]); ok { - acPower = &v + kw := v / 1000.0 + acPower = &kw } if v, ok := signal.Float64(snap["EnergyRemaining"]); ok { energyRemaining = &v diff --git a/internal/api/energyflow/handler_test.go b/internal/api/energyflow/handler_test.go index 4ab547d738..684a52e139 100644 --- a/internal/api/energyflow/handler_test.go +++ b/internal/api/energyflow/handler_test.go @@ -72,8 +72,8 @@ func TestEnergyFlow_Latest_UsesNow(t *testing.T) { stateFn: func(_ context.Context, v int64, at time.Time) (signal.State, error) { calls = append(calls, stateCallRecord{vehicleID: v, at: at}) return signal.State{ - "DCChargingPower": 48.5, - "ACChargingPower": 7.2, + "DCChargingPower": 48500.0, + "ACChargingPower": 7200.0, "EnergyRemaining": 62.3, "PackVoltage": 396.8, "PackCurrent": -120.4, @@ -126,6 +126,10 @@ func TestEnergyFlow_Latest_UsesNow(t *testing.T) { if body["charge_state"] != "Charging" { t.Fatalf("charge_state = %#v, want \"Charging\"", body["charge_state"]) } + if body["dc_charging_power"] != 48.5 || body["ac_charging_power"] != 7.2 { + t.Fatalf("charging power = (%v, %v), want (48.5, 7.2) kW", + body["dc_charging_power"], body["ac_charging_power"]) + } } // TestEnergyFlow_PropagatesError verifies that a StateReader.State diff --git a/internal/api/geofence/handler.go b/internal/api/geofence/handler.go index bdaf02d5a8..a72b523b25 100644 --- a/internal/api/geofence/handler.go +++ b/internal/api/geofence/handler.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "math" "net/http" "strings" @@ -68,47 +67,6 @@ func coalesceGeofenceRequestSpellings(req *geofenceCreateRequest) { } } -// geofenceCircleSegments controls the smoothness of the synthesized -// polygon. 32 segments keeps the WKT compact while staying visually round -// at typical city-block radii. -const geofenceCircleSegments = 32 - -// circleToPolygonWKT approximates a geodetic circle with a regular N-gon. -// Coordinates are emitted in WKT order: `POLYGON((lon lat, ..., lon lat))`, -// closing on the first vertex per the OGC spec. -func circleToPolygonWKT(latDeg, lonDeg, radiusMeters float64, segments int) string { - if segments < 3 { - segments = 3 - } - const metersPerDegLat = 111_320.0 - latRad := latDeg * math.Pi / 180.0 - metersPerDegLon := metersPerDegLat * math.Cos(latRad) - if metersPerDegLon < 1 { - // At the poles longitude collapses; clamp so we never divide by ~0. - metersPerDegLon = 1 - } - dLat := radiusMeters / metersPerDegLat - dLon := radiusMeters / metersPerDegLon - - var b strings.Builder - b.WriteString("POLYGON((") - for i := 0; i < segments; i++ { - theta := 2 * math.Pi * float64(i) / float64(segments) - lon := lonDeg + dLon*math.Sin(theta) - lat := latDeg + dLat*math.Cos(theta) - if i > 0 { - b.WriteByte(',') - } - fmt.Fprintf(&b, "%.7f %.7f", lon, lat) - } - // Close the ring by repeating the first vertex. - lonClose := lonDeg + dLon*math.Sin(0) - latClose := latDeg + dLat*math.Cos(0) - fmt.Fprintf(&b, ",%.7f %.7f", lonClose, latClose) - b.WriteString("))") - return b.String() -} - // decodeGeofenceWriteBody unmarshals the request and resolves whichever // geometry the client supplied into a populated *systemmodel.Geofence. // @@ -141,7 +99,7 @@ func decodeGeofenceWriteBody(body io.Reader) (*systemmodel.Geofence, *geofenceCr } if req.Latitude != nil && req.Longitude != nil && req.Radius != nil { - g.PolygonWKT = circleToPolygonWKT(*req.Latitude, *req.Longitude, *req.Radius, geofenceCircleSegments) + g.PolygonWKT = systemmodel.CircleToPolygonWKT(*req.Latitude, *req.Longitude, *req.Radius) } return g, &req, nil @@ -179,6 +137,16 @@ type Handler struct { // WithBulkStore overrides for tests. Never nil after NewHandler // unless both db and WithBulkStore(nil) were passed. bulk BulkStore + // rateRepo is the resolved charging-place discovery-review, archive, + // rate-CRUD, and preview/apply-repricing store used by + // rate_handler.go. Defaults to geofenceRepo; WithRateStore overrides + // for tests. See geofenceRateRepo in rate_handler.go for the exact + // method subset and rationale (same "small interface + Option + + // default-wire the concrete repo" idiom as BulkStore above, applied + // to the endpoints added for the geofence charging-place pricing + // feature so they get real route/validation/response-shape test + // coverage instead of a hand-duplicated "mirror"). + rateRepo geofenceRateRepo // audit is the optional per-mutation audit callback. nil → no-op. // Bound to "geofence" entity_type at the call site so the subpackage // does not need to know about the parent's audit categorization. @@ -201,6 +169,12 @@ type AuditFunc func(r *http.Request, action string, entityID *int64, detail stri // the constructor wire the repo. func WithBulkStore(s BulkStore) Option { return func(h *Handler) { h.bulk = s } } +// WithRateStore overrides the default rate/discovery/repricing store +// (which is the same *geofencedb.GeofenceRepo used by the CRUD methods +// and bulk store). Intended for tests; production code should construct +// via NewHandler(db) and let the constructor wire the repo. +func WithRateStore(s geofenceRateRepo) Option { return func(h *Handler) { h.rateRepo = s } } + // WithAuditFunc installs the audit callback invoked after a successful // bulk_delete. Without it, BulkUpdate skips the audit row but still // performs the delete. @@ -214,6 +188,7 @@ func NewHandler(db *database.DB, opts ...Option) *Handler { if db != nil { h.geofenceRepo = geofencedb.NewGeofenceRepo(db) h.bulk = h.geofenceRepo + h.rateRepo = h.geofenceRepo } for _, opt := range opts { opt(h) @@ -222,7 +197,15 @@ func NewHandler(db *database.DB, opts ...Option) *Handler { } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - geofences, err := h.geofenceRepo.GetAll(r.Context()) + var ( + geofences []*systemmodel.Geofence + err error + ) + if strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_archived")), "true") { + geofences, err = h.geofenceRepo.GetAllIncludingArchived(r.Context()) + } else { + geofences, err = h.geofenceRepo.GetAll(r.Context()) + } if err != nil { log.Error().Err(err).Msg("failed to list geofences") apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to list geofences")) @@ -350,6 +333,15 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, &merged) } +// Delete hard-deletes a geofence with no charging/drive history. A place +// that has ever been referenced by a charging session, rate, or drive +// endpoint (geofence_id / rate_id / start_geofence_id / end_geofence_id — +// none of which carry a DB-level FK, by design, to keep the telemetry hot +// path unblocked) is NEVER hard-deleted: the historical-integrity rule +// requires those places to go through POST /{geofenceID}/archive instead, +// so already-priced sessions keep resolving their place by id forever. A +// place with no history at all keeps working exactly as before this +// feature. func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { id, err := apiparams.URLParamInt64(r, "geofenceID") if err != nil { @@ -357,6 +349,17 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } + hasHistory, err := h.geofenceRepo.HasChargingHistory(r.Context(), id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("failed to check geofence charging history") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to check geofence history")) + return + } + if hasHistory { + apperror.Write(w, r, apperror.ErrGeofenceHasHistory) + return + } + if err := h.geofenceRepo.Delete(r.Context(), id); err != nil { log.Error().Err(err).Int64("id", id).Msg("failed to delete geofence") apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to delete geofence")) diff --git a/internal/api/geofence/handler_test.go b/internal/api/geofence/handler_test.go index 5bcd10ca07..407a6305ae 100644 --- a/internal/api/geofence/handler_test.go +++ b/internal/api/geofence/handler_test.go @@ -25,7 +25,7 @@ func TestCircleToPolygonWKT_RoundTripsCenterAndRadius(t *testing.T) { lon = -122.208886 radius = 100.0 ) - wkt := circleToPolygonWKT(lat, lon, radius, geofenceCircleSegments) + wkt := systemmodel.CircleToPolygonWKT(lat, lon, radius) if !strings.HasPrefix(wkt, "POLYGON((") || !strings.HasSuffix(wkt, "))") { t.Fatalf("WKT not well-formed: %q", wkt) } diff --git a/internal/api/geofence/rate_handler.go b/internal/api/geofence/rate_handler.go new file mode 100644 index 0000000000..e8e6feea26 --- /dev/null +++ b/internal/api/geofence/rate_handler.go @@ -0,0 +1,568 @@ +package geofence + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "strings" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/apperror" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + geofencedb "github.com/ev-dev-labs/teslasync/internal/database/geofence" + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + "github.com/ev-dev-labs/teslasync/internal/tracing" + + "github.com/rs/zerolog/log" +) + +// ============================================================================= +// rate_handler.go — charging-place discovery-review, archive, time-versioned +// rate CRUD, and preview/apply-repricing HTTP endpoints for the +// geofence-based charging-place pricing feature (migration +// 000228_geofence_charging_place_pricing). See internal/database/geofence's +// repo_discovery.go, repo_archive.go, repo_rates.go, repo_charging_summary.go +// for the business rules each endpoint delegates to. +// +// Every mutating handler here re-derives its response from the database +// after the write (rather than trusting the in-memory request) so the +// caller's optimistic cache update always reflects the authoritative row — +// same discipline as the existing Update handler in handler.go. +// ============================================================================= + +// geofenceRateRepo is the subset of *geofencedb.GeofenceRepo's methods used +// by this file's discovery-review, archive, rate-CRUD, and +// preview/apply-repricing endpoints. +// +// Declaring a narrow interface here — rather than referencing the +// concrete *geofencedb.GeofenceRepo type directly, as the pre-existing CRUD +// handlers in handler.go do — lets tests substitute a fake and get full +// route+validation+response-shape coverage without a live Postgres +// connection or a hand-duplicated "mirror" of each handler body (the +// approach handler_test.go's fakeGeofenceUpdateRepo had to fall back to +// for the one existing repo-touching CRUD handler). See rate_handler_test.go +// for the fake. +// +// Handler.rateRepo defaults to the same *geofencedb.GeofenceRepo instance +// used everywhere else (see NewHandler); WithRateStore overrides it in +// tests. Every method here is a 1:1 copy of a *geofencedb.GeofenceRepo +// method signature — keep this list in sync if repo signatures change. +type geofenceRateRepo interface { + GetByID(ctx context.Context, id int64) (*systemmodel.Geofence, error) + ListNeedsReview(ctx context.Context) ([]*systemmodel.Geofence, error) + ListActiveRatesNow(ctx context.Context) ([]*systemmodel.GeofenceRate, error) + Archive(ctx context.Context, id int64) error + Unarchive(ctx context.Context, id int64) error + MarkReviewed(ctx context.Context, id int64) error + ListRates(ctx context.Context, geofenceID int64) ([]*systemmodel.GeofenceRate, error) + CreateRate(ctx context.Context, gr *systemmodel.GeofenceRate) error + DeleteRate(ctx context.Context, geofenceID, rateID int64) error + PreviewApplyRate(ctx context.Context, scope systemmodel.GeofenceRateApplyScope) (*systemmodel.GeofenceRateImpactPreview, error) + ApplyRate(ctx context.Context, scope systemmodel.GeofenceRateApplyScope) (*systemmodel.GeofenceRateApplyResult, error) + ChargingSummaryByCurrency(ctx context.Context, geofenceID int64) ([]*systemmodel.GeofenceChargingSummary, error) + ChargingActivity(ctx context.Context, geofenceID int64, limit, offset int) ([]*systemmodel.GeofenceChargingActivity, error) +} + +// NeedsReview serves GET /geofences/needs-review — the "Needs Setup" queue +// of auto-discovered charging-place geofences awaiting a human to confirm +// name/type/location, oldest first. +func (h *Handler) NeedsReview(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.needs_review") + defer span.End() + + list, err := h.rateRepo.ListNeedsReview(ctx) + if err != nil { + tracing.EndSpan(span, err) + log.Error().Err(err).Msg("failed to list geofences needing review") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to list geofences needing review")) + return + } + httpx.WriteJSON(w, http.StatusOK, list) +} + +// CurrentRates serves GET /geofences/rates/current — the currently-active +// rate (if any) for every geofence in one round trip, powering the Charging +// Places list view's rate column without a per-row N+1 lookup. +func (h *Handler) CurrentRates(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.rates_current") + defer span.End() + + list, err := h.rateRepo.ListActiveRatesNow(ctx) + if err != nil { + tracing.EndSpan(span, err) + log.Error().Err(err).Msg("failed to list current geofence rates") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to list current geofence rates")) + return + } + httpx.WriteJSON(w, http.StatusOK, list) +} + +// loadGeofenceOr404 fetches a geofence by URL param id, writing a +// structured 400/404/500 response and returning ok=false when the caller +// must stop. Deliberately resolves archived places too (GetByID has no +// archived_at filter) so rate-history/charging-activity/summary endpoints +// keep working for a retired place per the historical-integrity rule. +func (h *Handler) loadGeofenceOr404(ctx context.Context, w http.ResponseWriter, r *http.Request, id int64) (*systemmodel.Geofence, bool) { + g, err := h.rateRepo.GetByID(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("failed to load geofence") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to load geofence")) + return nil, false + } + if g == nil { + apperror.Write(w, r, apperror.ErrGeofenceNotFound) + return nil, false + } + return g, true +} + +// respondWithGeofence re-fetches and returns the geofence at 200 after a +// successful state-changing action (Archive/Unarchive/MarkReviewed) so the +// caller's optimistic cache update has the authoritative row without a +// second client-issued round trip. Falls back to 204 (the mutation itself +// already succeeded) on the — unexpected — case that the re-fetch fails. +func (h *Handler) respondWithGeofence(ctx context.Context, w http.ResponseWriter, id int64) { + g, err := h.rateRepo.GetByID(ctx, id) + if err != nil || g == nil { + log.Warn().Err(err).Int64("geofence_id", id). + Msg("geofence mutation succeeded but response refresh failed") + w.WriteHeader(http.StatusNoContent) + return + } + httpx.WriteJSON(w, http.StatusOK, g) +} + +// Archive serves POST /geofences/{geofenceID}/archive. Idempotent: archiving +// an already-archived place is a no-op success. +func (h *Handler) Archive(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.archive") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id)) + + if err := h.rateRepo.Archive(ctx, id); err != nil { + if errors.Is(err, geofencedb.ErrGeofenceNotFound) { + apperror.Write(w, r, apperror.ErrGeofenceNotFound) + return + } + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("id", id).Msg("failed to archive geofence") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to archive geofence")) + return + } + h.respondWithGeofence(ctx, w, id) +} + +// Unarchive serves POST /geofences/{geofenceID}/unarchive. Idempotent: +// unarchiving an already-active place is a no-op success. +func (h *Handler) Unarchive(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.unarchive") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id)) + + if err := h.rateRepo.Unarchive(ctx, id); err != nil { + if errors.Is(err, geofencedb.ErrGeofenceNotFound) { + apperror.Write(w, r, apperror.ErrGeofenceNotFound) + return + } + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("id", id).Msg("failed to unarchive geofence") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to unarchive geofence")) + return + } + h.respondWithGeofence(ctx, w, id) +} + +// MarkReviewed serves POST /geofences/{geofenceID}/reviewed — clears +// needs_review once a human has confirmed/edited an auto-discovered +// place's name, type, or location. +func (h *Handler) MarkReviewed(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.mark_reviewed") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id)) + + if err := h.rateRepo.MarkReviewed(ctx, id); err != nil { + if errors.Is(err, geofencedb.ErrGeofenceNotFound) { + apperror.Write(w, r, apperror.ErrGeofenceNotFound) + return + } + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("id", id).Msg("failed to mark geofence reviewed") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to mark geofence reviewed")) + return + } + h.respondWithGeofence(ctx, w, id) +} + +// ListRates serves GET /geofences/{geofenceID}/rates — every rate version, +// newest effective_from first, the shape the rate-history UI panel renders +// directly. +func (h *Handler) ListRates(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.rates_list") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id)) + + if _, ok := h.loadGeofenceOr404(ctx, w, r, id); !ok { + return + } + rates, err := h.rateRepo.ListRates(ctx, id) + if err != nil { + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("geofence_id", id).Msg("failed to list geofence rates") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to list geofence rates")) + return + } + httpx.WriteJSON(w, http.StatusOK, rates) +} + +// geofenceRateCreateRequest is the wire shape accepted by CreateRate. +// RatePerWh/EffectiveFrom are pointers so omission (nil) can be +// distinguished from an explicit zero value. +type geofenceRateCreateRequest struct { + RatePerWh *float64 `json:"rate_per_wh"` + Currency string `json:"currency"` + EffectiveFrom *time.Time `json:"effective_from"` + EffectiveTo *time.Time `json:"effective_to,omitempty"` +} + +// validateRateRequestFields duplicates the repository-level rate-integrity +// checks (non-negative finite rate, uppercase ISO-4217 currency, ordered +// interval) at the handler boundary so a malformed request gets a precise +// 400 GEOFENCE_RATE_INVALID instead of a generic 500 from deep inside +// CreateRate — mirrors the existing validateGeofence/decodeGeofenceWriteBody +// split in handler.go. +func validateRateRequestFields(ratePerWh float64, currency string, effectiveFrom time.Time, effectiveTo *time.Time) error { + if math.IsNaN(ratePerWh) || math.IsInf(ratePerWh, 0) || + ratePerWh < 0 || ratePerWh >= 1_000_000 { + return fmt.Errorf("rate_per_wh must be finite and between 0 (inclusive) and 1000000 (exclusive)") + } + if len(currency) != 3 { + return fmt.Errorf("currency must be a 3-letter ISO 4217 code") + } + for _, c := range currency { + if c < 'A' || c > 'Z' { + return fmt.Errorf("currency must be an uppercase ISO 4217 code (got %q)", currency) + } + } + if effectiveFrom.IsZero() { + return fmt.Errorf("effective_from is required") + } + if effectiveTo != nil && !effectiveTo.After(effectiveFrom) { + return fmt.Errorf("effective_to must be after effective_from") + } + return nil +} + +// CreateRate serves POST /geofences/{geofenceID}/rates. Handles both +// "first-time setup" (no prior rate) and "add a new version" (a later +// effective_from that closes the prior unbounded interval) — see +// GeofenceRepo.CreateRate for the exact auto-close/no-overlap semantics. +// There is deliberately no separate "replace" endpoint: a correction is +// just another CreateRate call with an effective_from at or after the +// point the correction should take hold. +func (h *Handler) CreateRate(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.rates_create") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id)) + + var req geofenceRateCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apperror.Write(w, r, apperror.ErrInvalidJSON) + return + } + if req.RatePerWh == nil || req.EffectiveFrom == nil { + apperror.Write(w, r, apperror.ErrMissingField.WithMessage("rate_per_wh and effective_from are required")) + return + } + currency := strings.ToUpper(strings.TrimSpace(req.Currency)) + if err := validateRateRequestFields(*req.RatePerWh, currency, *req.EffectiveFrom, req.EffectiveTo); err != nil { + apperror.Write(w, r, apperror.ErrGeofenceRateInvalid.WithMessage(err.Error())) + return + } + + if _, ok := h.loadGeofenceOr404(ctx, w, r, id); !ok { + return + } + + gr := &systemmodel.GeofenceRate{ + GeofenceID: id, + RatePerWh: *req.RatePerWh, + Currency: currency, + EffectiveFrom: req.EffectiveFrom.UTC(), + } + if req.EffectiveTo != nil { + t := req.EffectiveTo.UTC() + gr.EffectiveTo = &t + } + + if err := h.rateRepo.CreateRate(ctx, gr); err != nil { + if errors.Is(err, geofencedb.ErrRateConflict) { + apperror.Write(w, r, apperror.ErrGeofenceRateConflict) + return + } + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("geofence_id", id).Msg("failed to create geofence rate") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to create geofence rate")) + return + } + log.Info().Int64("geofence_id", id).Int64("rate_id", gr.ID).Str("currency", gr.Currency). + Time("effective_from", gr.EffectiveFrom).Msg("geofence rate created") + httpx.WriteJSON(w, http.StatusCreated, gr) +} + +// DeleteRate serves DELETE /geofences/{geofenceID}/rates/{rateID}. Only an +// unused future schedule can be cancelled; effective history is immutable. +func (h *Handler) DeleteRate(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.rates_delete") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + rateID, err := apiparams.URLParamInt64(r, "rateID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid rate ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id), tracing.RateID(rateID)) + + if err := h.rateRepo.DeleteRate(ctx, id, rateID); err != nil { + switch { + case errors.Is(err, geofencedb.ErrRateNotFound): + apperror.Write(w, r, apperror.ErrGeofenceRateNotFound) + return + case errors.Is(err, geofencedb.ErrRateInUse): + apperror.Write(w, r, apperror.ErrGeofenceRateInUse) + return + case errors.Is(err, geofencedb.ErrRateImmutable): + apperror.Write(w, r, apperror.ErrGeofenceRateImmutable) + return + } + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("geofence_id", id).Int64("rate_id", rateID).Msg("failed to delete geofence rate") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to delete geofence rate")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// parseApplyScope resolves a preview/apply request's optional `from`/`to` +// RFC3339 query params into a systemmodel.GeofenceRateApplyScope. Both are +// optional; when absent the scope defaults to the rate's own effective +// interval (resolved downstream by the repository). Errors on unparseable +// timestamps or an inverted/empty [from, to) window. +func parseApplyScope(r *http.Request, geofenceID, rateID int64) (systemmodel.GeofenceRateApplyScope, error) { + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: geofenceID, RateID: rateID} + if v := strings.TrimSpace(r.URL.Query().Get("from")); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return scope, fmt.Errorf("invalid from: %w", err) + } + t = t.UTC() + scope.From = &t + } + if v := strings.TrimSpace(r.URL.Query().Get("to")); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return scope, fmt.Errorf("invalid to: %w", err) + } + t = t.UTC() + scope.To = &t + } + if scope.From != nil && scope.To != nil && !scope.To.After(*scope.From) { + return scope, fmt.Errorf("to must be after from") + } + return scope, nil +} + +// writeApplyScopeRepoError maps the shared PreviewApplyRate/ApplyRate error +// cases to structured responses. Returns true when it wrote a response +// (caller must stop); false means the error is an unexpected internal +// failure the caller should still handle (log + 500). +func writeApplyScopeRepoError(w http.ResponseWriter, r *http.Request, err error) bool { + switch { + case errors.Is(err, geofencedb.ErrGeofenceNotFound): + apperror.Write(w, r, apperror.ErrGeofenceNotFound) + return true + case errors.Is(err, geofencedb.ErrRateNotFound): + apperror.Write(w, r, apperror.ErrGeofenceRateNotFound) + return true + default: + return false + } +} + +// PreviewApplyRate serves GET /geofences/{geofenceID}/rates/{rateID}/preview +// — read-only, writes nothing. Reports how many sessions are matched (in +// scope by place + time), eligible (would actually be repriced), and +// protected (in scope but carry a manual/Tesla-actual cost or an existing +// cost with unknown provenance), plus the estimated total cost at this rate. +func (h *Handler) PreviewApplyRate(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.rate_preview_apply") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + rateID, err := apiparams.URLParamInt64(r, "rateID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid rate ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id), tracing.RateID(rateID)) + + scope, err := parseApplyScope(r, id, rateID) + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidInput.WithMessage(err.Error())) + return + } + + preview, err := h.rateRepo.PreviewApplyRate(ctx, scope) + if err != nil { + if writeApplyScopeRepoError(w, r, err) { + return + } + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("geofence_id", id).Int64("rate_id", rateID).Msg("failed to preview geofence rate apply") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to preview rate apply")) + return + } + httpx.WriteJSON(w, http.StatusOK, preview) +} + +// ApplyRate serves POST /geofences/{geofenceID}/rates/{rateID}/apply — the +// write-performing, explicit backfill/reprice action. Bounded to this +// geofence + rate's interval (optionally narrowed further by `from`/`to`), +// idempotent, and never overwrites a manual/Tesla-actual cost or an existing +// cost with unknown provenance. Matched legacy sessions are still attributed +// to the place so they appear in its activity and summaries — see +// GeofenceRepo.ApplyRate. +func (h *Handler) ApplyRate(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.rate_apply") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + rateID, err := apiparams.URLParamInt64(r, "rateID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid rate ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id), tracing.RateID(rateID)) + + scope, err := parseApplyScope(r, id, rateID) + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidInput.WithMessage(err.Error())) + return + } + + result, err := h.rateRepo.ApplyRate(ctx, scope) + if err != nil { + if writeApplyScopeRepoError(w, r, err) { + return + } + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("geofence_id", id).Int64("rate_id", rateID).Msg("failed to apply geofence rate") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to apply rate")) + return + } + log.Info().Int64("geofence_id", id).Int64("rate_id", rateID). + Int64("priced", result.PricedSessions).Int64("skipped", result.SkippedSessions). + Msg("geofence rate applied/backfilled") + httpx.WriteJSON(w, http.StatusOK, result) +} + +// ChargingSummary serves GET /geofences/{geofenceID}/charging-summary — +// priced-session totals grouped by currency (never summed across +// currencies). +func (h *Handler) ChargingSummary(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.charging_summary") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id)) + + if _, ok := h.loadGeofenceOr404(ctx, w, r, id); !ok { + return + } + summary, err := h.rateRepo.ChargingSummaryByCurrency(ctx, id) + if err != nil { + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("geofence_id", id).Msg("failed to load geofence charging summary") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to load charging summary")) + return + } + httpx.WriteJSON(w, http.StatusOK, summary) +} + +// ChargingActivity serves GET /geofences/{geofenceID}/charging-activity — +// paginated session-level feed (any pricing state) backing the +// rate-history / affected-sessions UI panels. +func (h *Handler) ChargingActivity(w http.ResponseWriter, r *http.Request) { + ctx, span := tracing.HandlerSpan(r.Context(), "geofence.charging_activity") + defer span.End() + + id, err := apiparams.URLParamInt64(r, "geofenceID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid geofence ID")) + return + } + span.SetAttributes(tracing.GeofenceID(id)) + + if _, ok := h.loadGeofenceOr404(ctx, w, r, id); !ok { + return + } + limit, offset := apiparams.Pagination(r) + activity, err := h.rateRepo.ChargingActivity(ctx, id, limit, offset) + if err != nil { + tracing.EndSpan(span, err) + log.Error().Err(err).Int64("geofence_id", id).Msg("failed to load geofence charging activity") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to load charging activity")) + return + } + httpx.WriteJSON(w, http.StatusOK, activity) +} diff --git a/internal/api/geofence/rate_handler_test.go b/internal/api/geofence/rate_handler_test.go new file mode 100644 index 0000000000..859729573a --- /dev/null +++ b/internal/api/geofence/rate_handler_test.go @@ -0,0 +1,940 @@ +package geofence + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "net/http/httptest" + "testing" + "time" + + geofencedb "github.com/ev-dev-labs/teslasync/internal/database/geofence" + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + + "github.com/go-chi/chi/v5" +) + +// ============================================================================= +// rate_handler_test.go — route/validation/response tests for the +// charging-place discovery-review, archive, rate-CRUD, and +// preview/apply-repricing endpoints added in rate_handler.go. +// +// Two testing techniques are used, deliberately kept separate: +// +// 1. Real production code, NO repo at all (h := NewHandler(nil)): every +// handler here validates URL params / request body / query params +// BEFORE touching h.rateRepo, so exercising ONLY the validation-failure +// branch is 100% safe against a nil rateRepo and requires zero fakes. +// This covers "route" (param wiring) and "validation" thoroughly using +// the exact shipped code path — not a mirror. +// 2. Real production code, WithRateStore(fake): geofenceRateRepo (defined +// in rate_handler.go) is a narrow, additive interface seam — the +// concrete *geofencedb.GeofenceRepo satisfies it structurally with zero +// changes, and Handler.rateRepo defaults to it in NewHandler. Swapping +// it for fakeRateRepo here exercises the REAL handler bodies end to end +// (decode → validate → repo call → error mapping → response envelope) +// without a live Postgres connection and without hand-duplicating any +// handler logic into the test file (contrast with handler_test.go's +// fakeGeofenceUpdateRepo/runGeofenceUpdateMerge mirror, which predates +// this seam and was the only option available for the CRUD handlers). +// ============================================================================= + +// --------------------------------------------------------------------------- +// fakeRateRepo — implements geofenceRateRepo in full. +// --------------------------------------------------------------------------- + +type fakeRateRepo struct { + getByIDResult *systemmodel.Geofence + getByIDErr error + getByIDCalls []int64 + + needsReviewResult []*systemmodel.Geofence + needsReviewErr error + + activeRatesNowResult []*systemmodel.GeofenceRate + activeRatesNowErr error + + archiveErr error + archiveCalls []int64 + + unarchiveErr error + unarchiveCalls []int64 + + markReviewedErr error + markReviewedCalls []int64 + + listRatesResult []*systemmodel.GeofenceRate + listRatesErr error + + createRateErr error + createRateCalls []systemmodel.GeofenceRate + createRateAssignID int64 + + deleteRateErr error + deleteRateCalls [][2]int64 + + previewResult *systemmodel.GeofenceRateImpactPreview + previewErr error + previewCalls []systemmodel.GeofenceRateApplyScope + + applyResult *systemmodel.GeofenceRateApplyResult + applyErr error + applyCalls []systemmodel.GeofenceRateApplyScope + + summaryResult []*systemmodel.GeofenceChargingSummary + summaryErr error + + activityResult []*systemmodel.GeofenceChargingActivity + activityErr error + activityCalls []struct{ limit, offset int } +} + +func (f *fakeRateRepo) GetByID(_ context.Context, id int64) (*systemmodel.Geofence, error) { + f.getByIDCalls = append(f.getByIDCalls, id) + if f.getByIDErr != nil { + return nil, f.getByIDErr + } + return f.getByIDResult, nil +} + +func (f *fakeRateRepo) ListNeedsReview(_ context.Context) ([]*systemmodel.Geofence, error) { + return f.needsReviewResult, f.needsReviewErr +} + +func (f *fakeRateRepo) ListActiveRatesNow(_ context.Context) ([]*systemmodel.GeofenceRate, error) { + return f.activeRatesNowResult, f.activeRatesNowErr +} + +func (f *fakeRateRepo) Archive(_ context.Context, id int64) error { + f.archiveCalls = append(f.archiveCalls, id) + return f.archiveErr +} + +func (f *fakeRateRepo) Unarchive(_ context.Context, id int64) error { + f.unarchiveCalls = append(f.unarchiveCalls, id) + return f.unarchiveErr +} + +func (f *fakeRateRepo) MarkReviewed(_ context.Context, id int64) error { + f.markReviewedCalls = append(f.markReviewedCalls, id) + return f.markReviewedErr +} + +func (f *fakeRateRepo) ListRates(_ context.Context, _ int64) ([]*systemmodel.GeofenceRate, error) { + return f.listRatesResult, f.listRatesErr +} + +func (f *fakeRateRepo) CreateRate(_ context.Context, gr *systemmodel.GeofenceRate) error { + f.createRateCalls = append(f.createRateCalls, *gr) + if f.createRateErr != nil { + return f.createRateErr + } + if f.createRateAssignID != 0 { + gr.ID = f.createRateAssignID + } + return nil +} + +func (f *fakeRateRepo) DeleteRate(_ context.Context, geofenceID, rateID int64) error { + f.deleteRateCalls = append(f.deleteRateCalls, [2]int64{geofenceID, rateID}) + return f.deleteRateErr +} + +func (f *fakeRateRepo) PreviewApplyRate(_ context.Context, scope systemmodel.GeofenceRateApplyScope) (*systemmodel.GeofenceRateImpactPreview, error) { + f.previewCalls = append(f.previewCalls, scope) + if f.previewErr != nil { + return nil, f.previewErr + } + return f.previewResult, nil +} + +func (f *fakeRateRepo) ApplyRate(_ context.Context, scope systemmodel.GeofenceRateApplyScope) (*systemmodel.GeofenceRateApplyResult, error) { + f.applyCalls = append(f.applyCalls, scope) + if f.applyErr != nil { + return nil, f.applyErr + } + return f.applyResult, nil +} + +func (f *fakeRateRepo) ChargingSummaryByCurrency(_ context.Context, _ int64) ([]*systemmodel.GeofenceChargingSummary, error) { + return f.summaryResult, f.summaryErr +} + +func (f *fakeRateRepo) ChargingActivity(_ context.Context, _ int64, limit, offset int) ([]*systemmodel.GeofenceChargingActivity, error) { + f.activityCalls = append(f.activityCalls, struct{ limit, offset int }{limit, offset}) + return f.activityResult, f.activityErr +} + +// --------------------------------------------------------------------------- +// shared test helpers +// --------------------------------------------------------------------------- + +// newRateRequest builds an httptest.Request with the given chi URL params +// injected, matching the router.go mount shape (/geofences/{geofenceID}/...). +func newRateRequest(method, target string, body io.Reader, params map[string]string) *http.Request { + r := httptest.NewRequest(method, target, body) + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func decodeErrorBody(t *testing.T, w *httptest.ResponseRecorder) map[string]string { + t.Helper() + var out map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode error body: %v; body=%s", err, w.Body.String()) + } + return out +} + +func wantErrorResponse(t *testing.T, w *httptest.ResponseRecorder, status int, code string) { + t.Helper() + if w.Code != status { + t.Fatalf("status = %d, want %d; body=%s", w.Code, status, w.Body.String()) + } + body := decodeErrorBody(t, w) + if body["code"] != code { + t.Errorf("code = %q, want %q (body=%s)", body["code"], code, w.Body.String()) + } +} + +func sampleGeofence(id int64) *systemmodel.Geofence { + return &systemmodel.Geofence{ + ID: id, + Name: "Test Place", + PolygonWKT: "POLYGON((-74.0 40.0,-74.001 40.0,-74.001 40.001,-74.0 40.001,-74.0 40.0))", + Origin: systemmodel.GeofenceOriginManual, + } +} + +// --------------------------------------------------------------------------- +// (1) Route + validation tests — real handler code, NewHandler(nil), no repo +// touch. These assert the exact shipped validation branch, not a mirror. +// --------------------------------------------------------------------------- + +func TestRateEndpoints_InvalidGeofenceID(t *testing.T) { + h := NewHandler(nil) + cases := []struct { + name string + fn http.HandlerFunc + }{ + {"Archive", h.Archive}, + {"Unarchive", h.Unarchive}, + {"MarkReviewed", h.MarkReviewed}, + {"ListRates", h.ListRates}, + {"CreateRate", h.CreateRate}, + {"DeleteRate", h.DeleteRate}, + {"PreviewApplyRate", h.PreviewApplyRate}, + {"ApplyRate", h.ApplyRate}, + {"ChargingSummary", h.ChargingSummary}, + {"ChargingActivity", h.ChargingActivity}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/x", nil, map[string]string{"geofenceID": "not-a-number", "rateID": "1"}) + tc.fn(w, r) + wantErrorResponse(t, w, http.StatusBadRequest, "VALIDATION_INVALID_ID") + }) + } +} + +func TestRateScopedEndpoints_InvalidRateID(t *testing.T) { + h := NewHandler(nil) + cases := []struct { + name string + fn http.HandlerFunc + }{ + {"DeleteRate", h.DeleteRate}, + {"PreviewApplyRate", h.PreviewApplyRate}, + {"ApplyRate", h.ApplyRate}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/x", nil, map[string]string{"geofenceID": "1", "rateID": "not-a-number"}) + tc.fn(w, r) + wantErrorResponse(t, w, http.StatusBadRequest, "VALIDATION_INVALID_ID") + }) + } +} + +func TestCreateRate_InvalidJSON(t *testing.T) { + h := NewHandler(nil) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/x", bytes.NewReader([]byte(`{not json`)), map[string]string{"geofenceID": "1"}) + h.CreateRate(w, r) + wantErrorResponse(t, w, http.StatusBadRequest, "VALIDATION_INVALID_JSON") +} + +func TestCreateRate_MissingRequiredFields(t *testing.T) { + h := NewHandler(nil) + cases := []struct { + name string + body string + }{ + {"missing rate_per_wh", `{"currency":"USD","effective_from":"2026-01-01T00:00:00Z"}`}, + {"missing effective_from", `{"rate_per_wh":0.0001,"currency":"USD"}`}, + {"empty body", `{}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/x", bytes.NewReader([]byte(tc.body)), map[string]string{"geofenceID": "1"}) + h.CreateRate(w, r) + wantErrorResponse(t, w, http.StatusBadRequest, "VALIDATION_MISSING_FIELD") + }) + } +} + +func TestCreateRate_InvalidFieldValues(t *testing.T) { + h := NewHandler(nil) + cases := []struct { + name string + body string + }{ + {"negative rate", `{"rate_per_wh":-0.0001,"currency":"USD","effective_from":"2026-01-01T00:00:00Z"}`}, + {"rate exceeds database precision bound", `{"rate_per_wh":1000000,"currency":"USD","effective_from":"2026-01-01T00:00:00Z"}`}, + {"currency too short", `{"rate_per_wh":0.0001,"currency":"US","effective_from":"2026-01-01T00:00:00Z"}`}, + {"currency too long", `{"rate_per_wh":0.0001,"currency":"USDD","effective_from":"2026-01-01T00:00:00Z"}`}, + {"currency with digit (still invalid after uppercasing)", `{"rate_per_wh":0.0001,"currency":"us1","effective_from":"2026-01-01T00:00:00Z"}`}, + {"effective_to before effective_from", `{"rate_per_wh":0.0001,"currency":"USD","effective_from":"2026-06-01T00:00:00Z","effective_to":"2026-01-01T00:00:00Z"}`}, + {"effective_to equal effective_from", `{"rate_per_wh":0.0001,"currency":"USD","effective_from":"2026-06-01T00:00:00Z","effective_to":"2026-06-01T00:00:00Z"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/x", bytes.NewReader([]byte(tc.body)), map[string]string{"geofenceID": "1"}) + h.CreateRate(w, r) + wantErrorResponse(t, w, http.StatusBadRequest, "GEOFENCE_RATE_INVALID") + }) + } +} + +func TestPreviewApplyRate_InvalidQueryParams(t *testing.T) { + h := NewHandler(nil) + cases := []struct { + name string + query string + }{ + {"unparseable from", "from=not-a-date"}, + {"unparseable to", "to=not-a-date"}, + {"to before from", "from=2026-06-01T00:00:00Z&to=2026-01-01T00:00:00Z"}, + {"to equal from", "from=2026-01-01T00:00:00Z&to=2026-01-01T00:00:00Z"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/x?"+tc.query, nil, map[string]string{"geofenceID": "1", "rateID": "2"}) + h.PreviewApplyRate(w, r) + wantErrorResponse(t, w, http.StatusBadRequest, "VALIDATION_INVALID_INPUT") + }) + } +} + +func TestApplyRate_InvalidQueryParams(t *testing.T) { + h := NewHandler(nil) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/x?from=garbage", nil, map[string]string{"geofenceID": "1", "rateID": "2"}) + h.ApplyRate(w, r) + wantErrorResponse(t, w, http.StatusBadRequest, "VALIDATION_INVALID_INPUT") +} + +// --------------------------------------------------------------------------- +// (2) Pure function unit tests — no HTTP, no repo. +// --------------------------------------------------------------------------- + +func TestValidateRateRequestFields(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + later := base.Add(24 * time.Hour) + earlier := base.Add(-24 * time.Hour) + cases := []struct { + name string + rate float64 + currency string + from time.Time + to *time.Time + wantErr bool + }{ + {"valid open interval", 0.0001, "USD", base, nil, false}, + {"valid closed interval", 0.0001, "USD", base, &later, false}, + {"valid zero rate", 0, "USD", base, nil, false}, + {"negative rate", -0.0001, "USD", base, nil, true}, + {"NaN rate", math.NaN(), "USD", base, nil, true}, + {"+Inf rate", math.Inf(1), "USD", base, nil, true}, + {"currency too short", 0.0001, "US", base, nil, true}, + {"currency too long", 0.0001, "USDD", base, nil, true}, + {"currency lowercase", 0.0001, "usd", base, nil, true}, + {"currency with digit", 0.0001, "US1", base, nil, true}, + {"zero effective_from", 0.0001, "USD", time.Time{}, nil, true}, + {"effective_to equal effective_from", 0.0001, "USD", base, &base, true}, + {"effective_to before effective_from", 0.0001, "USD", base, &earlier, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateRateRequestFields(tc.rate, tc.currency, tc.from, tc.to) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestParseApplyScope(t *testing.T) { + cases := []struct { + name string + query string + wantErr bool + wantFrom bool + wantTo bool + }{ + {"no params", "", false, false, false}, + {"valid from only", "from=2026-01-01T00:00:00Z", false, true, false}, + {"valid to only", "to=2026-01-01T00:00:00Z", false, false, true}, + {"valid from and to", "from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z", false, true, true}, + {"invalid from", "from=not-a-date", true, false, false}, + {"invalid to", "to=not-a-date", true, false, false}, + {"to before from", "from=2026-02-01T00:00:00Z&to=2026-01-01T00:00:00Z", true, false, false}, + {"to equal from", "from=2026-01-01T00:00:00Z&to=2026-01-01T00:00:00Z", true, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/x?"+tc.query, nil) + scope, err := parseApplyScope(r, 7, 9) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if scope.GeofenceID != 7 || scope.RateID != 9 { + t.Errorf("scope ids = (%d,%d), want (7,9)", scope.GeofenceID, scope.RateID) + } + if tc.wantFrom && scope.From == nil { + t.Error("expected From to be set") + } + if !tc.wantFrom && scope.From != nil { + t.Errorf("expected From nil, got %v", scope.From) + } + if tc.wantTo && scope.To == nil { + t.Error("expected To to be set") + } + if !tc.wantTo && scope.To != nil { + t.Errorf("expected To nil, got %v", scope.To) + } + if scope.From != nil && scope.From.Location() != time.UTC { + t.Errorf("From not normalized to UTC: %v", scope.From.Location()) + } + }) + } +} + +func TestWriteApplyScopeRepoError(t *testing.T) { + cases := []struct { + name string + err error + wantHandled bool + wantStatus int + wantCode string + }{ + {"geofence not found", geofencedb.ErrGeofenceNotFound, true, http.StatusNotFound, "GEOFENCE_NOT_FOUND"}, + {"rate not found", geofencedb.ErrRateNotFound, true, http.StatusNotFound, "GEOFENCE_RATE_NOT_FOUND"}, + {"unrelated error is not handled here", errors.New("boom"), false, 0, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/x", nil) + handled := writeApplyScopeRepoError(w, r, tc.err) + if handled != tc.wantHandled { + t.Fatalf("handled = %v, want %v", handled, tc.wantHandled) + } + if tc.wantHandled { + wantErrorResponse(t, w, tc.wantStatus, tc.wantCode) + } else if w.Body.Len() != 0 { + t.Errorf("unhandled error case wrote a response body: %s", w.Body.String()) + } + }) + } +} + +// --------------------------------------------------------------------------- +// (3) Full response-shape tests via WithRateStore(fake) — real handler +// bodies, real HTTP round trip, no mirror. +// --------------------------------------------------------------------------- + +func TestNeedsReview(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := &fakeRateRepo{needsReviewResult: []*systemmodel.Geofence{sampleGeofence(1), sampleGeofence(2)}} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + h.NeedsReview(w, httptest.NewRequest(http.MethodGet, "/geofences/needs-review", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var out []systemmodel.Geofence + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out) != 2 { + t.Errorf("len(out) = %d, want 2", len(out)) + } + }) + t.Run("repo error surfaces as 500", func(t *testing.T) { + fake := &fakeRateRepo{needsReviewErr: errors.New("db down")} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + h.NeedsReview(w, httptest.NewRequest(http.MethodGet, "/geofences/needs-review", nil)) + wantErrorResponse(t, w, http.StatusInternalServerError, "DB_QUERY_FAILED") + }) +} + +func TestCurrentRates(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := &fakeRateRepo{activeRatesNowResult: []*systemmodel.GeofenceRate{{ID: 1, GeofenceID: 1, RatePerWh: 0.0001, Currency: "USD"}}} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + h.CurrentRates(w, httptest.NewRequest(http.MethodGet, "/geofences/rates/current", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + }) + t.Run("repo error surfaces as 500", func(t *testing.T) { + fake := &fakeRateRepo{activeRatesNowErr: errors.New("db down")} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + h.CurrentRates(w, httptest.NewRequest(http.MethodGet, "/geofences/rates/current", nil)) + wantErrorResponse(t, w, http.StatusInternalServerError, "DB_QUERY_FAILED") + }) +} + +func TestArchive(t *testing.T) { + t.Run("success returns the updated geofence", func(t *testing.T) { + archived := sampleGeofence(1) + now := time.Now().UTC() + archived.ArchivedAt = &now + fake := &fakeRateRepo{getByIDResult: archived} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/archive", nil, map[string]string{"geofenceID": "1"}) + h.Archive(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if len(fake.archiveCalls) != 1 || fake.archiveCalls[0] != 1 { + t.Errorf("Archive calls = %v, want [1]", fake.archiveCalls) + } + var out systemmodel.Geofence + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.ArchivedAt == nil { + t.Error("response geofence missing archived_at") + } + }) + t.Run("idempotent re-archive is still a 200 success", func(t *testing.T) { + archived := sampleGeofence(1) + now := time.Now().UTC() + archived.ArchivedAt = &now + fake := &fakeRateRepo{getByIDResult: archived} // Archive() itself is a no-op idempotent success per repo contract + h := NewHandler(nil, WithRateStore(fake)) + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/archive", nil, map[string]string{"geofenceID": "1"}) + h.Archive(w, r) + if w.Code != http.StatusOK { + t.Fatalf("iteration %d: status = %d, want 200", i, w.Code) + } + } + }) + t.Run("not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{archiveErr: geofencedb.ErrGeofenceNotFound} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/99/archive", nil, map[string]string{"geofenceID": "99"}) + h.Archive(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + }) + t.Run("repo error surfaces as 500", func(t *testing.T) { + fake := &fakeRateRepo{archiveErr: errors.New("db down")} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/archive", nil, map[string]string{"geofenceID": "1"}) + h.Archive(w, r) + wantErrorResponse(t, w, http.StatusInternalServerError, "DB_QUERY_FAILED") + }) + t.Run("re-fetch failure after a successful mutation still reports success via 204", func(t *testing.T) { + fake := &fakeRateRepo{getByIDErr: errors.New("transient")} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/archive", nil, map[string]string{"geofenceID": "1"}) + h.Archive(w, r) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", w.Code, w.Body.String()) + } + }) +} + +func TestUnarchive(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: sampleGeofence(1)} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/unarchive", nil, map[string]string{"geofenceID": "1"}) + h.Unarchive(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if len(fake.unarchiveCalls) != 1 || fake.unarchiveCalls[0] != 1 { + t.Errorf("Unarchive calls = %v, want [1]", fake.unarchiveCalls) + } + }) + t.Run("not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{unarchiveErr: geofencedb.ErrGeofenceNotFound} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/99/unarchive", nil, map[string]string{"geofenceID": "99"}) + h.Unarchive(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + }) +} + +func TestMarkReviewed(t *testing.T) { + t.Run("success", func(t *testing.T) { + reviewed := sampleGeofence(1) + reviewed.NeedsReview = false + fake := &fakeRateRepo{getByIDResult: reviewed} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/reviewed", nil, map[string]string{"geofenceID": "1"}) + h.MarkReviewed(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if len(fake.markReviewedCalls) != 1 || fake.markReviewedCalls[0] != 1 { + t.Errorf("MarkReviewed calls = %v, want [1]", fake.markReviewedCalls) + } + }) + t.Run("not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{markReviewedErr: geofencedb.ErrGeofenceNotFound} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/99/reviewed", nil, map[string]string{"geofenceID": "99"}) + h.MarkReviewed(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + }) +} + +func TestListRates(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := &fakeRateRepo{ + getByIDResult: sampleGeofence(1), + listRatesResult: []*systemmodel.GeofenceRate{{ID: 1, GeofenceID: 1, RatePerWh: 0.0001, Currency: "USD"}}, + } + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/1/rates", nil, map[string]string{"geofenceID": "1"}) + h.ListRates(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var out []systemmodel.GeofenceRate + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out) != 1 { + t.Fatalf("len(out) = %d, want 1", len(out)) + } + }) + t.Run("geofence not found maps to 404 without calling ListRates", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: nil} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/99/rates", nil, map[string]string{"geofenceID": "99"}) + h.ListRates(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + }) +} + +func TestCreateRate(t *testing.T) { + t.Run("success normalizes currency and UTC-normalizes timestamps", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: sampleGeofence(1), createRateAssignID: 42} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"rate_per_wh":0.00012345,"currency":"usd","effective_from":"2026-01-01T00:00:00-05:00"}`)) + r := newRateRequest(http.MethodPost, "/geofences/1/rates", body, map[string]string{"geofenceID": "1"}) + h.CreateRate(w, r) + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", w.Code, w.Body.String()) + } + if len(fake.createRateCalls) != 1 { + t.Fatalf("CreateRate calls = %d, want 1", len(fake.createRateCalls)) + } + got := fake.createRateCalls[0] + if got.Currency != "USD" { + t.Errorf("currency = %q, want normalized %q", got.Currency, "USD") + } + if got.EffectiveFrom.Location() != time.UTC { + t.Errorf("effective_from not normalized to UTC: %v", got.EffectiveFrom.Location()) + } + if got.GeofenceID != 1 { + t.Errorf("geofence_id = %d, want 1", got.GeofenceID) + } + var out systemmodel.GeofenceRate + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.ID != 42 { + t.Errorf("response id = %d, want 42", out.ID) + } + }) + t.Run("geofence not found maps to 404 without calling CreateRate", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: nil} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"rate_per_wh":0.0001,"currency":"USD","effective_from":"2026-01-01T00:00:00Z"}`)) + r := newRateRequest(http.MethodPost, "/geofences/99/rates", body, map[string]string{"geofenceID": "99"}) + h.CreateRate(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + if len(fake.createRateCalls) != 0 { + t.Errorf("CreateRate was called despite missing geofence: %v", fake.createRateCalls) + } + }) + t.Run("conflict maps to 409", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: sampleGeofence(1), createRateErr: geofencedb.ErrRateConflict} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"rate_per_wh":0.0001,"currency":"USD","effective_from":"2026-01-01T00:00:00Z"}`)) + r := newRateRequest(http.MethodPost, "/geofences/1/rates", body, map[string]string{"geofenceID": "1"}) + h.CreateRate(w, r) + wantErrorResponse(t, w, http.StatusConflict, "GEOFENCE_RATE_CONFLICT") + }) + t.Run("repository failure maps to 500", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: sampleGeofence(1), createRateErr: errors.New("boom")} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"rate_per_wh":0.0001,"currency":"USD","effective_from":"2026-01-01T00:00:00Z"}`)) + r := newRateRequest(http.MethodPost, "/geofences/1/rates", body, map[string]string{"geofenceID": "1"}) + h.CreateRate(w, r) + wantErrorResponse(t, w, http.StatusInternalServerError, "DB_QUERY_FAILED") + }) +} + +func TestDeleteRate(t *testing.T) { + t.Run("success returns 204", func(t *testing.T) { + fake := &fakeRateRepo{} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodDelete, "/geofences/1/rates/5", nil, map[string]string{"geofenceID": "1", "rateID": "5"}) + h.DeleteRate(w, r) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", w.Code, w.Body.String()) + } + if len(fake.deleteRateCalls) != 1 || fake.deleteRateCalls[0] != [2]int64{1, 5} { + t.Errorf("DeleteRate calls = %v, want [[1 5]]", fake.deleteRateCalls) + } + }) + t.Run("not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{deleteRateErr: geofencedb.ErrRateNotFound} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodDelete, "/geofences/1/rates/99", nil, map[string]string{"geofenceID": "1", "rateID": "99"}) + h.DeleteRate(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_RATE_NOT_FOUND") + }) +} + +func TestPreviewApplyRate(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := &fakeRateRepo{previewResult: &systemmodel.GeofenceRateImpactPreview{ + GeofenceID: 1, RateID: 5, Currency: "USD", + MatchedSessions: 10, EligibleSessions: 7, ProtectedSessions: 3, + TotalEnergyWh: 50000, EstimatedCostDecimal: 5.5, + }} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/1/rates/5/preview", nil, map[string]string{"geofenceID": "1", "rateID": "5"}) + h.PreviewApplyRate(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var out systemmodel.GeofenceRateImpactPreview + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.EligibleSessions != 7 || out.ProtectedSessions != 3 { + t.Errorf("preview = %+v, want eligible=7 protected=3", out) + } + if len(fake.previewCalls) != 1 || fake.previewCalls[0].GeofenceID != 1 || fake.previewCalls[0].RateID != 5 { + t.Errorf("PreviewApplyRate scope = %+v", fake.previewCalls) + } + }) + t.Run("rate not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{previewErr: geofencedb.ErrRateNotFound} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/1/rates/99/preview", nil, map[string]string{"geofenceID": "1", "rateID": "99"}) + h.PreviewApplyRate(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_RATE_NOT_FOUND") + }) + t.Run("geofence not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{previewErr: geofencedb.ErrGeofenceNotFound} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/99/rates/5/preview", nil, map[string]string{"geofenceID": "99", "rateID": "5"}) + h.PreviewApplyRate(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + }) + t.Run("scope from/to query params flow through to the repo call", func(t *testing.T) { + fake := &fakeRateRepo{previewResult: &systemmodel.GeofenceRateImpactPreview{}} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/1/rates/5/preview?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z", nil, map[string]string{"geofenceID": "1", "rateID": "5"}) + h.PreviewApplyRate(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if len(fake.previewCalls) != 1 { + t.Fatalf("expected exactly one PreviewApplyRate call") + } + got := fake.previewCalls[0] + if got.From == nil || !got.From.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) { + t.Errorf("scope.From = %v, want 2026-01-01T00:00:00Z", got.From) + } + if got.To == nil || !got.To.Equal(time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)) { + t.Errorf("scope.To = %v, want 2026-02-01T00:00:00Z", got.To) + } + }) +} + +func TestApplyRate(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := &fakeRateRepo{applyResult: &systemmodel.GeofenceRateApplyResult{ + GeofenceID: 1, RateID: 5, Currency: "USD", + MatchedSessions: 10, PricedSessions: 7, SkippedSessions: 3, + TotalEnergyWh: 50000, TotalCostDecimal: 5.5, + }} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/rates/5/apply", nil, map[string]string{"geofenceID": "1", "rateID": "5"}) + h.ApplyRate(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var out systemmodel.GeofenceRateApplyResult + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.PricedSessions != 7 || out.SkippedSessions != 3 { + t.Errorf("result = %+v, want priced=7 skipped=3", out) + } + }) + t.Run("rate not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{applyErr: geofencedb.ErrRateNotFound} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/rates/99/apply", nil, map[string]string{"geofenceID": "1", "rateID": "99"}) + h.ApplyRate(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_RATE_NOT_FOUND") + }) + t.Run("unexpected repo error surfaces as 500", func(t *testing.T) { + fake := &fakeRateRepo{applyErr: errors.New("db down")} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodPost, "/geofences/1/rates/5/apply", nil, map[string]string{"geofenceID": "1", "rateID": "5"}) + h.ApplyRate(w, r) + wantErrorResponse(t, w, http.StatusInternalServerError, "DB_QUERY_FAILED") + }) +} + +func TestChargingSummary(t *testing.T) { + t.Run("success groups by currency", func(t *testing.T) { + fake := &fakeRateRepo{ + getByIDResult: sampleGeofence(1), + summaryResult: []*systemmodel.GeofenceChargingSummary{ + {GeofenceID: 1, Currency: "USD", SessionCount: 3, TotalEnergyWh: 10000, TotalCostDecimal: 1.5}, + {GeofenceID: 1, Currency: "EUR", SessionCount: 1, TotalEnergyWh: 2000, TotalCostDecimal: 0.3}, + }, + } + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/1/charging-summary", nil, map[string]string{"geofenceID": "1"}) + h.ChargingSummary(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var out []systemmodel.GeofenceChargingSummary + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out) != 2 { + t.Fatalf("len(out) = %d, want 2 (one per currency, never summed)", len(out)) + } + }) + t.Run("geofence not found maps to 404", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: nil} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/99/charging-summary", nil, map[string]string{"geofenceID": "99"}) + h.ChargingSummary(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + }) +} + +func TestChargingActivity(t *testing.T) { + t.Run("success passes pagination through", func(t *testing.T) { + fake := &fakeRateRepo{ + getByIDResult: sampleGeofence(1), + activityResult: []*systemmodel.GeofenceChargingActivity{{SessionID: 1, VehicleID: 2}}, + } + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/1/charging-activity?limit=10&offset=20", nil, map[string]string{"geofenceID": "1"}) + h.ChargingActivity(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if len(fake.activityCalls) != 1 || fake.activityCalls[0].limit != 10 || fake.activityCalls[0].offset != 20 { + t.Errorf("activityCalls = %+v, want limit=10 offset=20", fake.activityCalls) + } + }) + t.Run("geofence not found maps to 404 without calling ChargingActivity", func(t *testing.T) { + fake := &fakeRateRepo{getByIDResult: nil} + h := NewHandler(nil, WithRateStore(fake)) + w := httptest.NewRecorder() + r := newRateRequest(http.MethodGet, "/geofences/99/charging-activity", nil, map[string]string{"geofenceID": "99"}) + h.ChargingActivity(w, r) + wantErrorResponse(t, w, http.StatusNotFound, "GEOFENCE_NOT_FOUND") + if len(fake.activityCalls) != 0 { + t.Errorf("ChargingActivity was called despite missing geofence: %v", fake.activityCalls) + } + }) +} + +// TestNewHandler_RateStoreDefaultsToGeofenceRepo pins that a production +// NewHandler(db) call wires rateRepo to the SAME concrete instance as +// geofenceRepo/bulk — i.e. WithRateStore is test-only surface area, not a +// second production code path that could silently drift from the CRUD repo. +func TestNewHandler_RateStoreDefaultsToGeofenceRepo(t *testing.T) { + h := NewHandler(nil) + if h.rateRepo != nil { + t.Fatalf("with db=nil, rateRepo should stay nil (no fake installed); got %#v", h.rateRepo) + } + fake := &fakeRateRepo{} + h2 := NewHandler(nil, WithRateStore(fake)) + if h2.rateRepo != fake { + t.Fatal("WithRateStore did not install the fake") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 535b11a80b..53d0bea0c9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -209,7 +209,6 @@ import ( "github.com/ev-dev-labs/teslasync/internal/database" actioncenterdb "github.com/ev-dev-labs/teslasync/internal/database/actioncenter" advancedintelligencedb "github.com/ev-dev-labs/teslasync/internal/database/advancedintelligence" - ownershipinteldb "github.com/ev-dev-labs/teslasync/internal/database/ownershipintel" aidb "github.com/ev-dev-labs/teslasync/internal/database/ai" dbalert "github.com/ev-dev-labs/teslasync/internal/database/alert" auditdb "github.com/ev-dev-labs/teslasync/internal/database/audit" @@ -221,6 +220,7 @@ import ( geofencedb "github.com/ev-dev-labs/teslasync/internal/database/geofence" dbnotif "github.com/ev-dev-labs/teslasync/internal/database/notification" dbobs "github.com/ev-dev-labs/teslasync/internal/database/observability" + ownershipinteldb "github.com/ev-dev-labs/teslasync/internal/database/ownershipintel" quiethoursdb "github.com/ev-dev-labs/teslasync/internal/database/quiethours" settingsdb "github.com/ev-dev-labs/teslasync/internal/database/settings" signaldb "github.com/ev-dev-labs/teslasync/internal/database/signal" @@ -376,12 +376,12 @@ import ( "github.com/ev-dev-labs/teslasync/internal/app/actioncentersvc" "github.com/ev-dev-labs/teslasync/internal/app/adminobssvc" "github.com/ev-dev-labs/teslasync/internal/app/advancedintelligencesvc" - "github.com/ev-dev-labs/teslasync/internal/app/ownershipintelsvc" "github.com/ev-dev-labs/teslasync/internal/app/auditviewersvc" "github.com/ev-dev-labs/teslasync/internal/app/chargingsvc" "github.com/ev-dev-labs/teslasync/internal/app/dashboardsvc" "github.com/ev-dev-labs/teslasync/internal/app/exportsvc" "github.com/ev-dev-labs/teslasync/internal/app/gdprexportsvc" + "github.com/ev-dev-labs/teslasync/internal/app/ownershipintelsvc" "github.com/ev-dev-labs/teslasync/internal/app/vehiclesvc" handlermw "github.com/ev-dev-labs/teslasync/internal/handler/middleware" v1handlers "github.com/ev-dev-labs/teslasync/internal/handler/v1" @@ -391,6 +391,43 @@ import ( "github.com/ev-dev-labs/teslasync/internal/tracing" ) +type vehicleManagementRouteHandler interface { + VehicleOptions(http.ResponseWriter, *http.Request) + RefreshVehicleOptions(http.ResponseWriter, *http.Request) + VehicleSpecs(http.ResponseWriter, *http.Request) + RefreshVehicleSpecs(http.ResponseWriter, *http.Request) + SubscriptionEligibility(http.ResponseWriter, *http.Request) + RefreshSubscriptionEligibility(http.ResponseWriter, *http.Request) + UpgradeEligibility(http.ResponseWriter, *http.Request) + RefreshUpgradeEligibility(http.ResponseWriter, *http.Request) + WarrantyDetails(http.ResponseWriter, *http.Request) + RefreshWarrantyDetails(http.ResponseWriter, *http.Request) + VehiclePricing(http.ResponseWriter, *http.Request) + EnterpriseRoles(http.ResponseWriter, *http.Request) + RefreshEnterpriseRoles(http.ResponseWriter, *http.Request) + EnterprisePayer(http.ResponseWriter, *http.Request) +} + +func mountVehicleScopedManagementRoutes(r chi.Router, h vehicleManagementRouteHandler) { + r.Get("/options", h.VehicleOptions) + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/options/refresh", h.RefreshVehicleOptions) + r.Get("/specs", h.VehicleSpecs) + r.With(httprate.LimitByIP(2, 1*time.Minute)).Post("/specs/refresh", h.RefreshVehicleSpecs) + r.Get("/subscriptions", h.SubscriptionEligibility) + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/subscriptions/refresh", h.RefreshSubscriptionEligibility) + r.Get("/upgrades", h.UpgradeEligibility) + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/upgrades/refresh", h.RefreshUpgradeEligibility) + r.Get("/warranty", h.WarrantyDetails) + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/warranty/refresh", h.RefreshWarrantyDetails) + r.Get("/enterprise-roles", h.EnterpriseRoles) + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/enterprise-roles/refresh", h.RefreshEnterpriseRoles) + r.With(httprate.LimitByIP(2, 1*time.Minute)).Post("/enterprise-payer", h.EnterprisePayer) +} + +func mountAccountVehicleManagementRoutes(r chi.Router, h vehicleManagementRouteHandler) { + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/tesla/vehicle-pricing", h.VehiclePricing) +} + // NewRouter creates and configures the main HTTP router with all API routes, // middleware (logging, recovery, CORS, rate limiting, security headers), and // a static file server for the SPA frontend. It wires up handler dependencies @@ -3053,16 +3090,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie // Vehicle info: mobile access, options, specs r.Get("/mobile-enabled", vehicleInfoHandler.MobileEnabled) r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/mobile-enabled/refresh", vehicleInfoHandler.RefreshMobileEnabled) - r.Get("/options", vehicleInfoHandler.VehicleOptions) - r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/options/refresh", vehicleInfoHandler.RefreshVehicleOptions) - r.Get("/specs", vehicleInfoHandler.VehicleSpecs) - r.With(httprate.LimitByIP(2, 1*time.Minute)).Post("/specs/refresh", vehicleInfoHandler.RefreshVehicleSpecs) - - // Vehicle lifecycle: subscriptions & upgrades - r.Get("/subscriptions", vehicleInfoHandler.SubscriptionEligibility) - r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/subscriptions/refresh", vehicleInfoHandler.RefreshSubscriptionEligibility) - r.Get("/upgrades", vehicleInfoHandler.UpgradeEligibility) - r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/upgrades/refresh", vehicleInfoHandler.RefreshUpgradeEligibility) + mountVehicleScopedManagementRoutes(r, vehicleInfoHandler) // /guard endpoints restored. // Status + Events are read-only and rate-limit-free @@ -3224,21 +3252,46 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/profile/refresh", teslaUserProfileHandler.RefreshProfile) }) - // Tesla Warranty Details (account-level) - r.Get("/tesla/warranty", vehicleInfoHandler.WarrantyDetails) - r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/tesla/warranty/refresh", vehicleInfoHandler.RefreshWarrantyDetails) + // Tesla Vehicle Management account-level endpoints. + mountAccountVehicleManagementRoutes(r, vehicleInfoHandler) // Geofences r.Route("/geofences", func(r chi.Router) { r.Get("/", geofenceHandler.List) r.Post("/", geofenceHandler.Create) - // Bulk operations — kept ahead of the + // Bulk operations, the "Needs Setup" queue, and the bulk + // current-rates lookup are all kept ahead of the // {geofenceID} subrouter so chi matches the static path first. r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/bulk", geofenceHandler.BulkUpdate) + r.Get("/needs-review", geofenceHandler.NeedsReview) + r.Get("/rates/current", geofenceHandler.CurrentRates) r.Route("/{geofenceID}", func(r chi.Router) { r.Get("/", geofenceHandler.Get) r.Put("/", geofenceHandler.Update) r.Delete("/", geofenceHandler.Delete) + + // Charging-place discovery review + archive lifecycle + // (migration 000228_geofence_charging_place_pricing). + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/archive", geofenceHandler.Archive) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/unarchive", geofenceHandler.Unarchive) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/reviewed", geofenceHandler.MarkReviewed) + + // Time-versioned electricity rates. + r.Get("/rates", geofenceHandler.ListRates) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/rates", geofenceHandler.CreateRate) + r.Route("/rates/{rateID}", func(r chi.Router) { + r.With(httprate.LimitByIP(20, 1*time.Minute)).Delete("/", geofenceHandler.DeleteRate) + // Preview is read-only (no DB writes) but still + // rate-limited: it runs the same candidate-session + // scan ApplyRate does and must not become a free DoS + // vector against charging_sessions. + r.With(httprate.LimitByIP(30, 1*time.Minute)).Get("/preview", geofenceHandler.PreviewApplyRate) + r.With(httprate.LimitByIP(10, 1*time.Minute)).Post("/apply", geofenceHandler.ApplyRate) + }) + + // Read-only charging-activity views for this place. + r.Get("/charging-summary", geofenceHandler.ChargingSummary) + r.Get("/charging-activity", geofenceHandler.ChargingActivity) }) }) diff --git a/internal/api/router_vehicle_management_test.go b/internal/api/router_vehicle_management_test.go new file mode 100644 index 0000000000..38bab6b852 --- /dev/null +++ b/internal/api/router_vehicle_management_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +type vehicleManagementRouteFake struct { + operation string +} + +func (f *vehicleManagementRouteFake) respond(operation string, w http.ResponseWriter) { + f.operation = operation + w.WriteHeader(http.StatusNoContent) +} + +func (f *vehicleManagementRouteFake) VehicleOptions(w http.ResponseWriter, _ *http.Request) { + f.respond("options", w) +} + +func (f *vehicleManagementRouteFake) RefreshVehicleOptions(w http.ResponseWriter, _ *http.Request) { + f.respond("options_refresh", w) +} + +func (f *vehicleManagementRouteFake) VehicleSpecs(w http.ResponseWriter, _ *http.Request) { + f.respond("specs", w) +} + +func (f *vehicleManagementRouteFake) RefreshVehicleSpecs(w http.ResponseWriter, _ *http.Request) { + f.respond("specs_refresh", w) +} + +func (f *vehicleManagementRouteFake) SubscriptionEligibility(w http.ResponseWriter, _ *http.Request) { + f.respond("subscriptions", w) +} + +func (f *vehicleManagementRouteFake) RefreshSubscriptionEligibility(w http.ResponseWriter, _ *http.Request) { + f.respond("subscriptions_refresh", w) +} + +func (f *vehicleManagementRouteFake) UpgradeEligibility(w http.ResponseWriter, _ *http.Request) { + f.respond("upgrades", w) +} + +func (f *vehicleManagementRouteFake) RefreshUpgradeEligibility(w http.ResponseWriter, _ *http.Request) { + f.respond("upgrades_refresh", w) +} + +func (f *vehicleManagementRouteFake) WarrantyDetails(w http.ResponseWriter, _ *http.Request) { + f.respond("warranty", w) +} + +func (f *vehicleManagementRouteFake) RefreshWarrantyDetails(w http.ResponseWriter, _ *http.Request) { + f.respond("warranty_refresh", w) +} + +func (f *vehicleManagementRouteFake) VehiclePricing(w http.ResponseWriter, _ *http.Request) { + f.respond("pricing", w) +} + +func (f *vehicleManagementRouteFake) EnterpriseRoles(w http.ResponseWriter, _ *http.Request) { + f.respond("enterprise_roles", w) +} + +func (f *vehicleManagementRouteFake) RefreshEnterpriseRoles(w http.ResponseWriter, _ *http.Request) { + f.respond("enterprise_roles_refresh", w) +} + +func (f *vehicleManagementRouteFake) EnterprisePayer(w http.ResponseWriter, _ *http.Request) { + f.respond("enterprise_payer", w) +} + +func TestVehicleManagementRoutes(t *testing.T) { + tests := []struct { + name string + method string + path string + operation string + }{ + {"options cached GET", http.MethodGet, "/api/v1/vehicles/7/options", "options"}, + {"options refresh", http.MethodPost, "/api/v1/vehicles/7/options/refresh", "options_refresh"}, + {"paid specs cached GET", http.MethodGet, "/api/v1/vehicles/7/specs", "specs"}, + {"paid specs refresh", http.MethodPost, "/api/v1/vehicles/7/specs/refresh", "specs_refresh"}, + {"warranty cached GET", http.MethodGet, "/api/v1/vehicles/7/warranty", "warranty"}, + {"warranty refresh", http.MethodPost, "/api/v1/vehicles/7/warranty/refresh", "warranty_refresh"}, + {"subscriptions cached GET", http.MethodGet, "/api/v1/vehicles/7/subscriptions", "subscriptions"}, + {"subscriptions refresh", http.MethodPost, "/api/v1/vehicles/7/subscriptions/refresh", "subscriptions_refresh"}, + {"upgrades cached GET", http.MethodGet, "/api/v1/vehicles/7/upgrades", "upgrades"}, + {"upgrades refresh", http.MethodPost, "/api/v1/vehicles/7/upgrades/refresh", "upgrades_refresh"}, + {"pricing query", http.MethodPost, "/api/v1/tesla/vehicle-pricing", "pricing"}, + {"enterprise roles cached GET", http.MethodGet, "/api/v1/vehicles/7/enterprise-roles", "enterprise_roles"}, + {"enterprise roles refresh", http.MethodPost, "/api/v1/vehicles/7/enterprise-roles/refresh", "enterprise_roles_refresh"}, + {"enterprise payer", http.MethodPost, "/api/v1/vehicles/7/enterprise-payer", "enterprise_payer"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := &vehicleManagementRouteFake{} + router := chi.NewRouter() + router.Route("/api/v1", func(r chi.Router) { + mountAccountVehicleManagementRoutes(r, handler) + r.Route("/vehicles/{vehicleID}", func(r chi.Router) { + mountVehicleScopedManagementRoutes(r, handler) + }) + }) + + req := httptest.NewRequest(tt.method, tt.path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + if handler.operation != tt.operation { + t.Fatalf("operation = %q, want %q", handler.operation, tt.operation) + } + }) + } +} diff --git a/internal/api/telemetry/telemetry_sessions_charge_geofence_pricing.go b/internal/api/telemetry/telemetry_sessions_charge_geofence_pricing.go new file mode 100644 index 0000000000..79b7a9d309 --- /dev/null +++ b/internal/api/telemetry/telemetry_sessions_charge_geofence_pricing.go @@ -0,0 +1,380 @@ +package telemetry + +import ( + "context" + "fmt" + "strings" + "time" + + dbadmin "github.com/ev-dev-labs/teslasync/internal/database/admin" + "github.com/ev-dev-labs/teslasync/internal/metrics" + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + "github.com/ev-dev-labs/teslasync/internal/tracing" + + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" +) + +// ============================================================================= +// telemetry_sessions_charge_geofence_pricing.go — charging-place geofence +// discovery + rate-based cost calculation for the geofence-based +// charging-place pricing feature (migration +// 000228_geofence_charging_place_pricing). +// +// Live-session entry points are invoked via safeGo after a confirmed session +// row commits. The legacy-history path runs once at startup in its own safeGo. +// A raw cable-attached event never reaches either path, so discovery latency +// and failures cannot block or fail MQTT/telemetry ingest. +// ============================================================================= + +// applyGeofencePricingAsync resolves the charging-place geofence for a +// confirmed charging session and, when energy is available plus a rate is +// configured for the instant the session STARTED (never "now"), computes its +// cost. Calling it at session start creates/attaches the place promptly; +// calling it again at completion performs the monetary calculation. +// +// Sequence: +// 1. Match an existing geofence (any origin) containing (lat, lon) — see +// resolveChargingGeofence. Reuses the exact matching behavior that +// previously only fed `start_place` naming. +// 2. If nothing matches, idempotently discover a provisional +// charging-place geofence (75m circle, needs_review=true) via +// GeofenceRepo.FindOrCreateForCharging, deduplicated by an in-database +// advisory lock so concurrent completions at the same spot (e.g. two +// vehicles finishing a charge at the same Supercharger at once) can +// never create more than one place. +// 3. Whichever geofence resolved, attach its id + display name to the +// session, then look up the rate active at the session's StartedAt via +// GeofenceRepo.GetActiveRateAt and, if found, apply it via +// ChargingRepo.ApplyGeofenceTariff — which computes cost with +// PostgreSQL NUMERIC arithmetic and is guarded to only ever +// (re)write a session whose cost is unset or geofence/default-derived, +// NEVER a manual, Tesla-actual, or existing unknown-provenance cost. +// +// Retry-safety: every database operation this method calls +// (FindOrCreateForCharging, ApplyGeofenceTariff) is independently +// idempotent, so re-running this exact sequence for the same session after +// a transient failure can never create a duplicate geofence or replace a +// historical tariff with a different rate version. +func (t *TelemetrySessionTracker) applyGeofencePricingAsync(sessionID, vehicleID int64, lat, lon float64, startedAt time.Time, fields map[string]interface{}) { + gctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + gctx, span := tracing.StartSpan(gctx, "telemetry.charge_geofence_pricing", + tracing.ChargeID(sessionID), tracing.VehicleID(vehicleID)) + defer span.End() + + geofenceID, geofenceName, err := t.resolveChargingGeofence(gctx, sessionID, lat, lon) + if err != nil { + log.Warn().Err(err).Int64("session_id", sessionID).Int64("vehicle_id", vehicleID). + Msg("telemetry: geofence match/discovery failed for charging session; left unattributed") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + if uerr := t.chargeRepo.PartialUpdate(gctx, sessionID, fields); uerr != nil { + log.Warn().Err(uerr).Int64("session_id", sessionID). + Msg("telemetry: failed to persist charge enhanced fields after geofence resolution failure") + } + return + } + + fields["start_place"] = geofenceName + fields["geofence_id"] = geofenceID + if err := t.chargeRepo.PartialUpdate(gctx, sessionID, fields); err != nil { + log.Warn().Err(err).Int64("session_id", sessionID).Int64("geofence_id", geofenceID). + Msg("telemetry: failed to attach geofence to charging session") + } + + rate, err := t.geofenceRepo.GetActiveRateAt(gctx, geofenceID, startedAt) + if err != nil { + metrics.GeofenceRateApplyTotal.WithLabelValues("error").Inc() + log.Warn().Err(err).Int64("session_id", sessionID).Int64("geofence_id", geofenceID). + Msg("telemetry: geofence rate lookup failed") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return + } + if rate == nil { + // No rate configured for this place/instant yet — leave + // cost_source/cost_decimal/cost_currency unset (implicitly + // "unknown"); the Charging Places UI surfaces this as a place + // needing rate setup, not an error. + metrics.GeofenceRateApplyTotal.WithLabelValues("no_rate").Inc() + return + } + + applied, err := t.chargeRepo.ApplyGeofenceTariff(gctx, sessionID, geofenceID, rate.ID, rate.RatePerWh, rate.Currency) + if err != nil { + metrics.GeofenceRateApplyTotal.WithLabelValues("error").Inc() + log.Warn().Err(err).Int64("session_id", sessionID).Int64("geofence_id", geofenceID).Int64("rate_id", rate.ID). + Msg("telemetry: failed to apply geofence tariff to charging session") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return + } + if applied { + metrics.GeofenceRateApplyTotal.WithLabelValues("applied").Inc() + } else { + // ApplyGeofenceTariff's WHERE guard correctly declined to touch a + // session that already carries a protected cost, or has no + // energy-added value yet — expected, not an error. + metrics.GeofenceRateApplyTotal.WithLabelValues("skipped").Inc() + } +} + +const chargingPlaceHistoryBackfillBatch = 100 + +// StartChargingPlaceHistoryBackfill starts the one-shot, panic-isolated +// startup pass that discovers places for completed sessions predating the +// Charging Places feature. +func (t *TelemetrySessionTracker) StartChargingPlaceHistoryBackfill(ctx context.Context) { + if t == nil { + return + } + safeGo("charging_place_history_backfill", func() { + t.BackfillChargingPlaces(ctx) + }) +} + +// BackfillChargingPlaces attaches completed legacy charging sessions to an +// existing or newly-discovered place. The rate active at session start wins; +// when no historical interval exists, the rate active at startup is applied +// as default_estimate provenance. Existing actual or unknown-provenance costs +// remain protected by the repository write guards. +func (t *TelemetrySessionTracker) BackfillChargingPlaces(ctx context.Context) { + if t == nil || t.geofenceRepo == nil || t.chargeRepo == nil { + return + } + + ctx, span := tracing.StartSpan(ctx, "telemetry.charging_place_history_backfill") + var runErr error + defer func() { tracing.EndSpan(span, runErr) }() + + now := time.Now().UTC() + var afterID int64 + processed := 0 + outcomes := map[string]int{} + + for { + if ctx.Err() != nil { + break + } + candidates, err := t.geofenceRepo.ListChargingPlaceBackfillCandidates( + ctx, + afterID, + chargingPlaceHistoryBackfillBatch, + ) + if err != nil { + runErr = err + log.Error(). + Err(err). + Str("trace_id", span.SpanContext().TraceID().String()). + Msg("charging place history backfill: failed to load candidates") + break + } + if len(candidates) == 0 { + break + } + + for _, candidate := range candidates { + if ctx.Err() != nil { + break + } + afterID = candidate.SessionID + outcome, err := t.backfillChargingPlace(ctx, candidate, now) + if err != nil { + outcome = "error" + log.Warn(). + Err(err). + Int64("session_id", candidate.SessionID). + Int64("vehicle_id", candidate.VehicleID). + Msg("charging place history backfill: session deferred until next startup") + } + processed++ + outcomes[outcome]++ + metrics.GeofenceHistoricalBackfillTotal.WithLabelValues(outcome).Inc() + } + + if len(candidates) < chargingPlaceHistoryBackfillBatch { + break + } + } + + span.SetAttributes( + attribute.Int("charging_place_backfill.processed", processed), + attribute.Int("charging_place_backfill.historical_rate", outcomes["historical_rate"]), + attribute.Int("charging_place_backfill.current_estimate", outcomes["current_estimate"]), + attribute.Int("charging_place_backfill.attributed_only", outcomes["attributed_only"]), + attribute.Int("charging_place_backfill.skipped", outcomes["skipped"]), + attribute.Int("charging_place_backfill.errors", outcomes["error"]), + ) + if runErr != nil { + log.Warn(). + Int("processed", processed). + Int("historical_rate", outcomes["historical_rate"]). + Int("current_estimate", outcomes["current_estimate"]). + Int("attributed_only", outcomes["attributed_only"]). + Int("skipped", outcomes["skipped"]). + Int("errors", outcomes["error"]). + Msg("charging place history backfill stopped before backlog drained") + return + } + log.Info(). + Int("processed", processed). + Int("historical_rate", outcomes["historical_rate"]). + Int("current_estimate", outcomes["current_estimate"]). + Int("attributed_only", outcomes["attributed_only"]). + Int("skipped", outcomes["skipped"]). + Int("errors", outcomes["error"]). + Msg("charging place history backfill complete") +} + +func (t *TelemetrySessionTracker) backfillChargingPlace(ctx context.Context, candidate *systemmodel.ChargingPlaceBackfillCandidate, now time.Time) (string, error) { + if candidate == nil { + return "", fmt.Errorf("charging place history backfill: nil candidate") + } + suggestedName := "" + if candidate.StartPlace != nil { + suggestedName = strings.TrimSpace(*candidate.StartPlace) + } + geofenceID, geofenceName, err := t.resolveChargingGeofenceWithName( + ctx, + candidate.SessionID, + candidate.StartLat, + candidate.StartLng, + suggestedName, + false, + ) + if err != nil { + return "", err + } + if err := t.chargeRepo.PartialUpdate(ctx, candidate.SessionID, map[string]interface{}{ + "start_place": geofenceName, + "geofence_id": geofenceID, + }); err != nil { + return "", err + } + + historicalRate, err := t.geofenceRepo.GetActiveRateAt(ctx, geofenceID, candidate.StartedAt) + if err != nil { + return "", err + } + if historicalRate != nil { + applied, err := t.chargeRepo.ApplyGeofenceTariff( + ctx, + candidate.SessionID, + geofenceID, + historicalRate.ID, + historicalRate.RatePerWh, + historicalRate.Currency, + ) + if err != nil { + return "", err + } + if applied { + return "historical_rate", nil + } + return "skipped", nil + } + + currentRate, err := t.geofenceRepo.GetActiveRateAt(ctx, geofenceID, now) + if err != nil { + return "", err + } + if currentRate == nil { + return "attributed_only", nil + } + applied, err := t.geofenceRepo.ApplyCurrentRateEstimate( + ctx, + candidate.SessionID, + geofenceID, + currentRate.ID, + now, + ) + if err != nil { + return "", err + } + if applied { + return "current_estimate", nil + } + return "skipped", nil +} + +// resolveChargingGeofence matches (or, failing that, discovers) the +// charging-place geofence for (lat, lon), returning its id and display +// name. Counts the outcome via metrics.GeofenceDiscoveryTotal. +func (t *TelemetrySessionTracker) resolveChargingGeofence(ctx context.Context, sessionID int64, lat, lon float64) (int64, string, error) { + return t.resolveChargingGeofenceWithName(ctx, sessionID, lat, lon, "", true) +} + +func (t *TelemetrySessionTracker) resolveChargingGeofenceWithName( + ctx context.Context, + sessionID int64, + lat, lon float64, + suggestedName string, + allowReverseGeocode bool, +) (int64, string, error) { + if geofences, err := t.geofenceRepo.FindByCoordinates(ctx, lat, lon); err == nil && len(geofences) > 0 { + metrics.GeofenceDiscoveryTotal.WithLabelValues("matched").Inc() + return geofences[0].ID, geofences[0].Name, nil + } + + if strings.TrimSpace(suggestedName) == "" && allowReverseGeocode { + suggestedName = t.suggestChargingPlaceName(ctx, lat, lon) + } + discovered, created, err := t.geofenceRepo.FindOrCreateForCharging(ctx, lat, lon, suggestedName) + if err != nil { + metrics.GeofenceDiscoveryTotal.WithLabelValues("error").Inc() + return 0, "", err + } + if created { + metrics.GeofenceDiscoveryTotal.WithLabelValues("created").Inc() + log.Info().Int64("session_id", sessionID).Int64("geofence_id", discovered.ID).Str("name", discovered.Name). + Msg("telemetry: auto-discovered charging-place geofence") + } else { + // Lost the discovery race to a concurrent caller (or a previous + // attempt for this exact session already created it) — the + // advisory-lock re-check inside FindOrCreateForCharging returned + // the winner's place instead of creating a duplicate. + metrics.GeofenceDiscoveryTotal.WithLabelValues("matched").Inc() + } + return discovered.ID, discovered.Name, nil +} + +// suggestChargingPlaceName resolves a friendly name for a possibly-new +// charging place: the places cache first (no external call), else reverse +// geocoding (cached afterward for next time). Returns "" — NOT an error — +// when neither source has an answer; FindOrCreateForCharging substitutes a +// safe neutral name in that case. +func (t *TelemetrySessionTracker) suggestChargingPlaceName(ctx context.Context, lat, lon float64) string { + if t.placesCache != nil { + if cached, err := t.placesCache.FindNearby(ctx, lat, lon, 50); err == nil && cached != nil { + _ = t.placesCache.IncrementHitCount(ctx, cached.ID) + return cached.DisplayName + } + } + if t.geocoder == nil { + return "" + } + + result, err := t.geocoder.ReverseGeocode(ctx, lat, lon) + if err != nil { + return "" + } + if result == nil { + // A Geocoder implementation returning (nil, nil) — "no result, no + // error" — is a valid, common outcome (see resolveAndUpdateAddress's + // identical guard in telemetry_sessions_drive_tracking.go), not an + // error: fall back to the safe neutral name FindOrCreateForCharging + // substitutes for an empty string. + return "" + } + name := result.ShortName() + if t.placesCache != nil { + _ = t.placesCache.Upsert(ctx, &dbadmin.PlaceCacheEntry{ + Latitude: lat, Longitude: lon, DisplayName: name, Source: "geocoding", + City: ptrStrOrNil(result.City), State: ptrStrOrNil(result.State), + Country: ptrStrOrNil(result.Country), Postcode: ptrStrOrNil(result.PostCode), + }) + } + return name +} diff --git a/internal/api/telemetry/telemetry_sessions_charge_geofence_pricing_test.go b/internal/api/telemetry/telemetry_sessions_charge_geofence_pricing_test.go new file mode 100644 index 0000000000..b1da7fcd4b --- /dev/null +++ b/internal/api/telemetry/telemetry_sessions_charge_geofence_pricing_test.go @@ -0,0 +1,726 @@ +package telemetry + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + dto "github.com/prometheus/client_model/go" + + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/ev-dev-labs/teslasync/internal/metrics" + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" +) + +// ============================================================================= +// telemetry_sessions_charge_geofence_pricing_test.go — tracker-layer tests for +// applyGeofencePricingAsync (migration 000228_geofence_charging_place_pricing). +// +// Two contracts are pinned here, per the feature's non-negotiable design: +// +// 1. Rate attribution keys off the session's STARTED_AT, never "now" — a +// session that began before a rate's cutover must retain the OLD rate +// forever, even though "now" (when this async leg actually runs, which +// may be seconds after the session started, and is always long after +// any historical cutover in these fixtures) falls inside a newer rate's +// window. +// 2. Discovery/pricing failures — including a fully unreachable database — +// are logged/counted and NEVER propagated to the caller: the function +// has no return value, cannot block indefinitely (its context carries a +// 15s deadline), and (via the safeGo wrapper installed at its only call +// site in telemetry_sessions_charge_tracking.go) cannot crash the +// process even if something inside it were to panic. +// 3. An automatic retry can reapply the same pinned rate, but cannot replace +// an existing geofence-tariff cost with a different rate version. +// Historical repricing remains exclusive to the explicit preview/apply +// endpoint. +// +// TelemetrySessionTracker's repo fields (chargeRepo, geofenceRepo, ...) are +// concrete *xxxdb.XxxRepo types, not interfaces — there is no fake-pool test +// seam for this package (see place_label_repair_test.go's identical +// rationale). These tests reuse that file's DATABASE_URL/TESLASYNC_TEST_DSN +// gating (repairDSNOrSkip) and skip cleanly wherever no reachable database +// with migration 000228 applied is configured, mirroring +// database/energy/roundtrip_test.go and place_label_repair_test.go. +// ============================================================================= + +// openGeofencePricingDB mirrors openRepairDB but asserts on the columns this +// feature's migration (000228) adds instead of place_label_version. +func openGeofencePricingDB(t *testing.T) *database.DB { + t.Helper() + pool, err := pgxpool.New(context.Background(), repairDSNOrSkip(t)) + if err != nil { + t.Skipf("cannot open pool: %v", err) + } + t.Cleanup(pool.Close) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := pool.Ping(ctx); err != nil { + t.Skipf("cannot reach database: %v", err) + } + db := &database.DB{Pool: pool} + if _, err := pool.Exec(ctx, `SELECT geofence_id, rate_id, cost_source FROM charging_sessions LIMIT 0`); err != nil { + t.Skipf("charging_sessions geofence-pricing columns missing; migration 000228 not applied: %v", err) + } + if _, err := pool.Exec(ctx, `SELECT rate_per_wh, currency, effective_from, effective_to FROM geofence_rates LIMIT 0`); err != nil { + t.Skipf("geofence_rates table missing; migration 000228 not applied: %v", err) + } + return db +} + +// counterValue reads the current scalar value of a prometheus Counter without +// pulling in the prometheus/client_golang/prometheus/testutil package. Local +// copy of the identically-named helper in internal/api/api_call_log_middleware_test.go +// (that package is not importable from this subpackage's test binary). +func counterValue(c interface { + Write(*dto.Metric) error +}) float64 { + pb := &dto.Metric{} + if err := c.Write(pb); err != nil { + return 0 + } + return pb.GetCounter().GetValue() +} + +// seedPricingGeofence inserts one geofence centered at (lat, lon) with a 75m +// circle (the same radius FindOrCreateForCharging uses), removed via +// t.Cleanup. FindByCoordinates has no spatial index — it scans every +// non-archived geofence row and filters in Go — so distinct, deliberately +// improbable test coordinates keep concurrently-run tests in this file from +// ever matching each other's fixtures. +func seedPricingGeofence(t *testing.T, db *database.DB, lat, lon float64, name string) int64 { + t.Helper() + ctx := context.Background() + wkt := systemmodel.CircleToPolygonWKT(lat, lon, 75) + var id int64 + err := db.Pool.QueryRow(ctx, ` +INSERT INTO geofences (name, polygon_wkt, category, enabled, origin, needs_review) +VALUES ($1, $2, 'custom', true, 'manual', false) +RETURNING id`, name, wkt).Scan(&id) + if err != nil { + t.Fatalf("seed geofence: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM geofences WHERE id = $1`, id) + }) + return id +} + +// seedPricingRate inserts one geofence_rates version, removed via t.Cleanup. +// effectiveTo may be zero-value (time.Time{}) to mean "open-ended" (NULL). +func seedPricingRate(t *testing.T, db *database.DB, geofenceID int64, ratePerWh float64, currency string, effectiveFrom, effectiveTo time.Time) int64 { + t.Helper() + ctx := context.Background() + var effTo *time.Time + if !effectiveTo.IsZero() { + effTo = &effectiveTo + } + var id int64 + err := db.Pool.QueryRow(ctx, ` +INSERT INTO geofence_rates (geofence_id, rate_per_wh, currency, effective_from, effective_to) +VALUES ($1, $2, $3, $4, $5) +RETURNING id`, geofenceID, ratePerWh, currency, effectiveFrom, effTo).Scan(&id) + if err != nil { + t.Fatalf("seed rate: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM geofence_rates WHERE id = $1`, id) + }) + return id +} + +// seedPricingSession inserts one charging_sessions row started at startedAt +// with the given energy (nil for "no energy yet"), removed via t.Cleanup. +func seedPricingSession(t *testing.T, db *database.DB, vehicleID int64, startedAt time.Time, energyWh *float64) int64 { + t.Helper() + ctx := context.Background() + var id int64 + err := db.Pool.QueryRow(ctx, ` +INSERT INTO charging_sessions (vehicle_id, started_at, total_energy_added_wh) +VALUES ($1, $2, $3) +RETURNING id`, vehicleID, startedAt, energyWh).Scan(&id) + if err != nil { + t.Fatalf("seed charging session: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM charging_sessions WHERE id = $1`, id) + }) + return id +} + +func seedCompletedLegacyPricingSession( + t *testing.T, + db *database.DB, + vehicleID int64, + startedAt time.Time, + lat, lon float64, + startPlace string, + energyWh float64, +) int64 { + t.Helper() + var id int64 + err := db.Pool.QueryRow(context.Background(), ` +INSERT INTO charging_sessions ( + vehicle_id, started_at, ended_at, start_lat, start_lng, + start_place, total_energy_added_wh +) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id`, + vehicleID, + startedAt, + startedAt.Add(time.Hour), + lat, + lon, + startPlace, + energyWh, + ).Scan(&id) + if err != nil { + t.Fatalf("seed completed legacy charging session: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM charging_sessions WHERE id = $1`, id) + }) + return id +} + +// pricingSessionRow is what the tests read back from charging_sessions after +// calling applyGeofencePricingAsync. +type pricingSessionRow struct { + geofenceID *int64 + rateID *int64 + costSource *string + costDecimal *string // read as text to assert exact NUMERIC output, no float parsing + costCurr *string +} + +func readPricingSession(t *testing.T, db *database.DB, sessionID int64) pricingSessionRow { + t.Helper() + var row pricingSessionRow + err := db.Pool.QueryRow(context.Background(), ` +SELECT geofence_id, rate_id, cost_source, cost_decimal::text, cost_currency + FROM charging_sessions WHERE id = $1`, sessionID). + Scan(&row.geofenceID, &row.rateID, &row.costSource, &row.costDecimal, &row.costCurr) + if err != nil { + t.Fatalf("read charging session %d: %v", sessionID, err) + } + return row +} + +func countGeofences(t *testing.T, db *database.DB) int { + t.Helper() + var n int + if err := db.Pool.QueryRow(context.Background(), `SELECT count(*) FROM geofences`).Scan(&n); err != nil { + t.Fatalf("count geofences: %v", err) + } + return n +} + +// Deliberately-improbable, far-apart fixture coordinates so this file's +// tests (and any concurrent geofence created by a genuinely different +// process on a shared dev database) cannot spatially collide with one +// another. Each constant is used by exactly one test below. +const ( + pricingLatBeforeCutover = 12.1000 + pricingLonBeforeCutover = -45.1000 + pricingLatNoRate = 13.2000 + pricingLonNoRate = -46.2000 + pricingLatDiscover = 14.3000 + pricingLonDiscover = -47.3000 + pricingLatPinnedRate = 15.4000 + pricingLonPinnedRate = -48.4000 + pricingLatLegacyCurrent = 16.5000 + pricingLonLegacyCurrent = -49.5000 + pricingLatLegacyHistory = 17.6000 + pricingLonLegacyHistory = -50.6000 + pricingLatLegacyActual = 18.7000 + pricingLonLegacyActual = -51.7000 +) + +// TestApplyGeofencePricingAsync_UsesStartedAt_NotNow is the central proof +// required by this feature: a session that STARTED before a rate cutover +// must be priced with the OLD rate, even though the async pricing leg +// always actually executes well after that historical cutover (i.e. "now" +// falls inside the newer, open-ended rate's window in every one of these +// fixtures). A regression that swapped GetActiveRateAt's instant argument +// for time.Now() would silently apply the NEW rate/rate_id instead — this +// test fails loudly if that happens. +func TestApplyGeofencePricingAsync_UsesStartedAt_NotNow(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence(t, db, pricingLatBeforeCutover, pricingLonBeforeCutover, "Old Rate Fixture Place") + + cutover := time.Date(2021, 6, 15, 0, 0, 0, 0, time.UTC) + rateOldID := seedPricingRate(t, db, geofenceID, 0.0001, "USD", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), cutover) + rateNewID := seedPricingRate(t, db, geofenceID, 0.00012, "USD", cutover, time.Time{}) + if rateOldID == rateNewID { + t.Fatalf("fixture bug: old and new rate ids must differ") + } + + startedAt := time.Date(2020, 6, 1, 0, 0, 0, 0, time.UTC) // squarely inside the OLD rate's window + sessionID := seedPricingSession(t, db, 810001, startedAt, floatPtr(10000)) + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + tracker.applyGeofencePricingAsync(sessionID, 810001, pricingLatBeforeCutover, pricingLonBeforeCutover, startedAt, map[string]interface{}{}) + + row := readPricingSession(t, db, sessionID) + if row.rateID == nil || *row.rateID != rateOldID { + t.Fatalf("rate_id = %v, want the OLD rate %d (session started before the cutover)", row.rateID, rateOldID) + } + if row.geofenceID == nil || *row.geofenceID != geofenceID { + t.Fatalf("geofence_id = %v, want %d", row.geofenceID, geofenceID) + } + if row.costSource == nil || *row.costSource != "geofence_tariff" { + t.Fatalf("cost_source = %v, want geofence_tariff", row.costSource) + } + // 10000 Wh * 0.0001 currency/Wh = 1.0000 exactly. + if row.costDecimal == nil || *row.costDecimal != "1.0000" { + t.Fatalf("cost_decimal = %v, want 1.0000 (10000 Wh at the OLD 0.0001/Wh rate)", row.costDecimal) + } + if row.costCurr == nil || *row.costCurr != "USD" { + t.Fatalf("cost_currency = %v, want USD", row.costCurr) + } +} + +// TestApplyGeofencePricingAsync_UsesStartedAt_AtAndAfterCutover is the +// companion boundary case: a session starting exactly AT (or after) the +// cutover instant must get the NEW rate — pinning the half-open +// [effective_from, effective_to) semantics end-to-end through the tracker, +// not just at the repository layer (already covered by +// internal/database/geofence's own rate tests). +func TestApplyGeofencePricingAsync_UsesStartedAt_AtAndAfterCutover(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence(t, db, pricingLatBeforeCutover+0.5, pricingLonBeforeCutover-0.5, "New Rate Fixture Place") + + cutover := time.Date(2021, 6, 15, 0, 0, 0, 0, time.UTC) + _ = seedPricingRate(t, db, geofenceID, 0.0001, "USD", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), cutover) + rateNewID := seedPricingRate(t, db, geofenceID, 0.00012, "USD", cutover, time.Time{}) + + sessionID := seedPricingSession(t, db, 810002, cutover, floatPtr(10000)) // exactly at the boundary + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + tracker.applyGeofencePricingAsync(sessionID, 810002, pricingLatBeforeCutover+0.5, pricingLonBeforeCutover-0.5, cutover, map[string]interface{}{}) + + row := readPricingSession(t, db, sessionID) + if row.rateID == nil || *row.rateID != rateNewID { + t.Fatalf("rate_id = %v, want the NEW rate %d (session started exactly at the cutover, which is inclusive of the new period)", row.rateID, rateNewID) + } + // 10000 Wh * 0.00012 currency/Wh = 1.2000 exactly. + if row.costDecimal == nil || *row.costDecimal != "1.2000" { + t.Fatalf("cost_decimal = %v, want 1.2000 (10000 Wh at the NEW 0.00012/Wh rate)", row.costDecimal) + } +} + +func TestApplyGeofencePricingAsync_DifferentPinnedRateRequiresExplicitApply(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence(t, db, pricingLatPinnedRate, pricingLonPinnedRate, "Pinned Rate Fixture Place") + startedAt := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + activeRateID := seedPricingRate(t, db, geofenceID, 0.00012, "USD", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), time.Time{}) + sessionID := seedPricingSession(t, db, 810006, startedAt, floatPtr(10000)) + pinnedRateID := activeRateID + 1_000_000 + + if _, err := db.Pool.Exec(context.Background(), ` +UPDATE charging_sessions +SET geofence_id = $2, + rate_id = $3, + cost_source = 'geofence_tariff', + cost_decimal = 9.99, + cost_currency = 'USD' +WHERE id = $1`, sessionID, geofenceID, pinnedRateID); err != nil { + t.Fatalf("seed pinned tariff: %v", err) + } + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + tracker.applyGeofencePricingAsync( + sessionID, + 810006, + pricingLatPinnedRate, + pricingLonPinnedRate, + startedAt, + map[string]interface{}{}, + ) + + row := readPricingSession(t, db, sessionID) + if row.rateID == nil || *row.rateID != pinnedRateID { + t.Fatalf("rate_id = %v, want pinned historical rate %d", row.rateID, pinnedRateID) + } + if row.costDecimal == nil || *row.costDecimal != "9.9900" { + t.Fatalf("cost_decimal = %v, want protected historical cost 9.9900", row.costDecimal) + } +} + +func TestBackfillChargingPlace_UsesCurrentRateAsLegacyEstimate(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence( + t, + db, + pricingLatLegacyCurrent, + pricingLonLegacyCurrent, + "Legacy Current-Rate Place", + ) + now := time.Now().UTC().Truncate(time.Second) + currentRateID := seedPricingRate(t, db, geofenceID, 0.00012, "USD", now.Add(-24*time.Hour), time.Time{}) + startedAt := time.Date(2020, 4, 1, 0, 0, 0, 0, time.UTC) + sessionID := seedCompletedLegacyPricingSession( + t, + db, + 810007, + startedAt, + pricingLatLegacyCurrent, + pricingLonLegacyCurrent, + "Old Garage Label", + 10000, + ) + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + outcome, err := tracker.backfillChargingPlace(context.Background(), &systemmodel.ChargingPlaceBackfillCandidate{ + SessionID: sessionID, + VehicleID: 810007, + StartedAt: startedAt, + StartLat: pricingLatLegacyCurrent, + StartLng: pricingLonLegacyCurrent, + StartPlace: strPtr("Old Garage Label"), + }, now) + if err != nil { + t.Fatalf("backfillChargingPlace: %v", err) + } + if outcome != "current_estimate" { + t.Fatalf("outcome=%q, want current_estimate", outcome) + } + + row := readPricingSession(t, db, sessionID) + if row.geofenceID == nil || *row.geofenceID != geofenceID { + t.Fatalf("geofence_id=%v, want %d", row.geofenceID, geofenceID) + } + if row.rateID == nil || *row.rateID != currentRateID { + t.Fatalf("rate_id=%v, want today's rate %d", row.rateID, currentRateID) + } + if row.costSource == nil || *row.costSource != systemmodel.CostSourceDefaultEstimate { + t.Fatalf("cost_source=%v, want default_estimate", row.costSource) + } + if row.costDecimal == nil || *row.costDecimal != "1.2000" { + t.Fatalf("cost_decimal=%v, want 1.2000", row.costDecimal) + } +} + +func TestBackfillChargingPlace_HistoricalRateWinsOverCurrentEstimate(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence( + t, + db, + pricingLatLegacyHistory, + pricingLonLegacyHistory, + "Legacy Historical-Rate Place", + ) + cutover := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + historicalRateID := seedPricingRate( + t, + db, + geofenceID, + 0.00010, + "USD", + time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC), + cutover, + ) + _ = seedPricingRate(t, db, geofenceID, 0.00020, "USD", cutover, time.Time{}) + startedAt := time.Date(2020, 4, 1, 0, 0, 0, 0, time.UTC) + sessionID := seedCompletedLegacyPricingSession( + t, + db, + 810008, + startedAt, + pricingLatLegacyHistory, + pricingLonLegacyHistory, + "Historical Garage", + 10000, + ) + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + outcome, err := tracker.backfillChargingPlace(context.Background(), &systemmodel.ChargingPlaceBackfillCandidate{ + SessionID: sessionID, + VehicleID: 810008, + StartedAt: startedAt, + StartLat: pricingLatLegacyHistory, + StartLng: pricingLonLegacyHistory, + StartPlace: strPtr("Historical Garage"), + }, time.Now().UTC()) + if err != nil { + t.Fatalf("backfillChargingPlace: %v", err) + } + if outcome != "historical_rate" { + t.Fatalf("outcome=%q, want historical_rate", outcome) + } + + row := readPricingSession(t, db, sessionID) + if row.rateID == nil || *row.rateID != historicalRateID { + t.Fatalf("rate_id=%v, want historical rate %d", row.rateID, historicalRateID) + } + if row.costSource == nil || *row.costSource != systemmodel.CostSourceGeofenceTariff { + t.Fatalf("cost_source=%v, want geofence_tariff", row.costSource) + } + if row.costDecimal == nil || *row.costDecimal != "1.0000" { + t.Fatalf("cost_decimal=%v, want 1.0000", row.costDecimal) + } +} + +func TestBackfillChargingPlace_PreservesActualCostWhileAttachingPlace(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence( + t, + db, + pricingLatLegacyActual, + pricingLonLegacyActual, + "Legacy Actual-Cost Place", + ) + now := time.Now().UTC().Truncate(time.Second) + _ = seedPricingRate(t, db, geofenceID, 0.00012, "USD", now.Add(-24*time.Hour), time.Time{}) + startedAt := time.Date(2020, 4, 1, 0, 0, 0, 0, time.UTC) + sessionID := seedCompletedLegacyPricingSession( + t, + db, + 810009, + startedAt, + pricingLatLegacyActual, + pricingLonLegacyActual, + "Actual Cost Garage", + 10000, + ) + if _, err := db.Pool.Exec(context.Background(), ` +UPDATE charging_sessions +SET cost_decimal = 7.25, + cost_currency = 'USD', + cost_source = 'manual' +WHERE id = $1`, sessionID); err != nil { + t.Fatalf("seed manual cost: %v", err) + } + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + outcome, err := tracker.backfillChargingPlace(context.Background(), &systemmodel.ChargingPlaceBackfillCandidate{ + SessionID: sessionID, + VehicleID: 810009, + StartedAt: startedAt, + StartLat: pricingLatLegacyActual, + StartLng: pricingLonLegacyActual, + StartPlace: strPtr("Actual Cost Garage"), + }, now) + if err != nil { + t.Fatalf("backfillChargingPlace: %v", err) + } + if outcome != "skipped" { + t.Fatalf("outcome=%q, want skipped", outcome) + } + + row := readPricingSession(t, db, sessionID) + if row.geofenceID == nil || *row.geofenceID != geofenceID { + t.Fatalf("geofence_id=%v, want %d", row.geofenceID, geofenceID) + } + if row.rateID != nil { + t.Fatalf("rate_id=%v, want nil for protected actual cost", row.rateID) + } + if row.costSource == nil || *row.costSource != systemmodel.CostSourceManual { + t.Fatalf("cost_source=%v, want manual", row.costSource) + } + if row.costDecimal == nil || *row.costDecimal != "7.2500" { + t.Fatalf("cost_decimal=%v, want protected 7.2500", row.costDecimal) + } +} + +// TestApplyGeofencePricingAsync_NoRateConfigured_LeavesSessionUnpriced +// proves a matched geofence with no rate at all leaves the session's cost +// fields untouched (implicitly "unknown", not an error) and is observable +// via the no_rate metrics outcome — the Charging Places UI's "needs rate +// setup" signal depends on this being silent, not a thrown error. +func TestApplyGeofencePricingAsync_NoRateConfigured_LeavesSessionUnpriced(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence(t, db, pricingLatNoRate, pricingLonNoRate, "No Rate Fixture Place") + sessionID := seedPricingSession(t, db, 810003, time.Now().UTC(), floatPtr(5000)) + + before := counterValue(metrics.GeofenceRateApplyTotal.WithLabelValues("no_rate")) + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + tracker.applyGeofencePricingAsync(sessionID, 810003, pricingLatNoRate, pricingLonNoRate, time.Now().UTC(), map[string]interface{}{}) + + after := counterValue(metrics.GeofenceRateApplyTotal.WithLabelValues("no_rate")) + if after != before+1 { + t.Fatalf("GeofenceRateApplyTotal{no_rate} = %v -> %v, want +1", before, after) + } + + row := readPricingSession(t, db, sessionID) + if row.costSource != nil { + t.Fatalf("cost_source = %v, want nil/unset (no rate configured)", row.costSource) + } + if row.geofenceID == nil || *row.geofenceID != geofenceID { + t.Fatalf("geofence_id = %v, want %d (geofence still attaches even without a rate)", row.geofenceID, geofenceID) + } +} + +// TestApplyGeofencePricingAsync_DiscoversNewGeofenceWhenNoneMatches proves +// the tracker's full match-or-discover pipeline: with no existing geofence +// at these coordinates, a provisional charging-place geofence is created +// (origin=charging_discovery, needs_review=true) and attached to the +// session, exercising the "auto-create" half of "match vs auto-create". +func TestApplyGeofencePricingAsync_DiscoversNewGeofenceWhenNoneMatches(t *testing.T) { + db := openGeofencePricingDB(t) + sessionID := seedPricingSession(t, db, 810004, time.Now().UTC(), floatPtr(5000)) + + before := countGeofences(t, db) + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + tracker.applyGeofencePricingAsync(sessionID, 810004, pricingLatDiscover, pricingLonDiscover, time.Now().UTC(), map[string]interface{}{}) + after := countGeofences(t, db) + if after != before+1 { + t.Fatalf("geofence count = %d -> %d, want +1 (exactly one provisional place created)", before, after) + } + + row := readPricingSession(t, db, sessionID) + if row.geofenceID == nil { + t.Fatalf("geofence_id = nil, want the newly-discovered geofence attached") + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM geofences WHERE id = $1`, *row.geofenceID) + }) + + var origin string + var needsReview bool + if err := db.Pool.QueryRow(context.Background(), + `SELECT origin, needs_review FROM geofences WHERE id = $1`, *row.geofenceID).Scan(&origin, &needsReview); err != nil { + t.Fatalf("read discovered geofence: %v", err) + } + if origin != "charging_discovery" { + t.Fatalf("origin = %q, want charging_discovery", origin) + } + if !needsReview { + t.Fatalf("needs_review = false, want true for an auto-discovered place") + } +} + +// TestApplyGeofencePricingAsync_ZeroCoordinates_NeverCreatesGeofence proves +// the "no position behavior" requirement at the tracker layer: (0,0) (the +// null-island sentinel for "no GPS fix yet") must never create a geofence, +// and the resulting discovery failure must not prevent the session's other +// enhanced fields from being persisted or otherwise fail the caller. +func TestApplyGeofencePricingAsync_ZeroCoordinates_NeverCreatesGeofence(t *testing.T) { + db := openGeofencePricingDB(t) + sessionID := seedPricingSession(t, db, 810005, time.Now().UTC(), floatPtr(5000)) + + beforeGeofences := countGeofences(t, db) + beforeErrors := counterValue(metrics.GeofenceDiscoveryTotal.WithLabelValues("error")) + + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{}, nil) + tracker.applyGeofencePricingAsync(sessionID, 810005, 0, 0, time.Now().UTC(), map[string]interface{}{"charger_type": "AC"}) + + afterGeofences := countGeofences(t, db) + if afterGeofences != beforeGeofences { + t.Fatalf("geofence count = %d -> %d, want unchanged for (0,0) coordinates", beforeGeofences, afterGeofences) + } + afterErrors := counterValue(metrics.GeofenceDiscoveryTotal.WithLabelValues("error")) + if afterErrors != beforeErrors+1 { + t.Fatalf("GeofenceDiscoveryTotal{error} = %v -> %v, want +1", beforeErrors, afterErrors) + } + + row := readPricingSession(t, db, sessionID) + if row.geofenceID != nil { + t.Fatalf("geofence_id = %v, want nil (discovery must fail closed for (0,0))", row.geofenceID) + } + // The other enhanced fields passed in by the caller must still make it + // to the row even though geofence resolution failed — this is the + // "discovery failure does not fail charge completion" contract: the + // function falls back to persisting fields as-is instead of losing them. + var chargerType *string + if err := db.Pool.QueryRow(context.Background(), `SELECT charger_type FROM charging_sessions WHERE id = $1`, sessionID).Scan(&chargerType); err != nil { + t.Fatalf("read charger_type: %v", err) + } + if chargerType == nil || *chargerType != "AC" { + t.Fatalf("charger_type = %v, want AC (fields must still be persisted after a discovery failure)", chargerType) + } +} + +// TestApplyGeofencePricingAsync_BrokenDatabaseNeverPanics is the strongest +// form of "discovery/pricing failure does not fail charge completion": with +// every underlying database call failing (a closed connection pool), the +// function must still return normally — no panic, no propagated error (it +// has no return value to propagate one through), and no indefinite block. +// Needs no reachable database at all — pgxpool.New connects lazily, so a +// pool pointed at a deliberately-unused local port opens successfully and +// then fails fast on first use, letting this test run in any environment +// without DATABASE_URL/TESLASYNC_TEST_DSN configured. +func TestApplyGeofencePricingAsync_BrokenDatabaseNeverPanics(t *testing.T) { + pool, err := pgxpool.New(context.Background(), "postgres://nope:nope@127.0.0.1:1/nonexistent?connect_timeout=1") + if err != nil { + t.Fatalf("pgxpool.New (lazy connect) unexpectedly failed: %v", err) + } + defer pool.Close() + + db := &database.DB{Pool: pool} + tracker := NewTelemetrySessionTracker(db, nil, &stubGeocoder{err: context.DeadlineExceeded}, nil) + + beforeErrors := counterValue(metrics.GeofenceDiscoveryTotal.WithLabelValues("error")) + + panicked := callAndRecover(func() { + tracker.applyGeofencePricingAsync(999999001, 999999001, 37.7749, -122.4194, time.Now().UTC(), map[string]interface{}{"charger_type": "DC"}) + }) + if panicked != nil { + t.Fatalf("applyGeofencePricingAsync panicked with every DB call failing: %v", panicked) + } + + afterErrors := counterValue(metrics.GeofenceDiscoveryTotal.WithLabelValues("error")) + if afterErrors != beforeErrors+1 { + t.Fatalf("GeofenceDiscoveryTotal{error} = %v -> %v, want +1 (failure must still be observable)", beforeErrors, afterErrors) + } +} + +// callAndRecover runs fn and returns the recovered panic value, or nil if fn +// returned normally. Used to make "this call must not panic" an explicit, +// readable assertion instead of relying on the test runner's own implicit +// panic-equals-failure behavior. +func callAndRecover(fn func()) (recovered interface{}) { + defer func() { + recovered = recover() + }() + fn() + return nil +} + +// TestSafeGo_RecoversPanicAndReturnsImmediately pins the panic-recovery +// wrapper installed around the real call site (completeChargeLocked's +// `safeGo("charge_geofence_pricing", func() { t.applyGeofencePricingAsync(...) })` +// in telemetry_sessions_charge_tracking.go): a panicking background +// goroutine must never crash the process, and the caller (standing in for +// completeChargeLocked, which must finish and release its lock regardless +// of what the fire-and-forget pricing leg does) must not block waiting for +// it. +func TestSafeGo_RecoversPanicAndReturnsImmediately(t *testing.T) { + const label = "test_charge_geofence_pricing_panic" + before := counterValue(metrics.PanicsRecovered.WithLabelValues(label)) + + done := make(chan struct{}) + start := time.Now() + safeGo(label, func() { + defer close(done) + panic("boom: simulated geofence pricing panic") + }) + elapsed := time.Since(start) + if elapsed > time.Second { + t.Fatalf("safeGo blocked its caller for %v — it must return immediately", elapsed) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("panicking goroutine never completed its deferred cleanup — recover() did not run") + } + + // Poll briefly: close(done) unwinds as part of fn's own deferred calls, + // which run BEFORE the panic propagates up to safeGo's recover() (and + // thus before the metrics increment) — so done closing only proves fn's + // frame unwound, not that the outer recover has run yet. + deadline := time.Now().Add(2 * time.Second) + for { + after := counterValue(metrics.PanicsRecovered.WithLabelValues(label)) + if after == before+1 { + break + } + if time.Now().After(deadline) { + t.Fatalf("PanicsRecovered{%s} = %v -> %v, want +1", label, before, after) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/api/telemetry/telemetry_sessions_charge_tracking.go b/internal/api/telemetry/telemetry_sessions_charge_tracking.go index 22018afaf6..175402ba1f 100644 --- a/internal/api/telemetry/telemetry_sessions_charge_tracking.go +++ b/internal/api/telemetry/telemetry_sessions_charge_tracking.go @@ -11,7 +11,6 @@ import ( "github.com/jackc/pgx/v5" "github.com/rs/zerolog/log" - dbadmin "github.com/ev-dev-labs/teslasync/internal/database/admin" "github.com/ev-dev-labs/teslasync/internal/enums" "github.com/ev-dev-labs/teslasync/internal/events" "github.com/ev-dev-labs/teslasync/internal/signal" @@ -92,8 +91,9 @@ type streamingCharge struct { lastTelemetryWrite time.Time // Location - Latitude *float64 - Longitude *float64 + Latitude *float64 + Longitude *float64 + LocationFresh bool // Temperature accumulators InsideTempSum, OutsideTempSum float64 @@ -122,6 +122,64 @@ type streamingCharge struct { state signal.StateReader } +func freshChargeCoordinateValue(v *signal.Value, at time.Time) bool { + if at.IsZero() { + at = time.Now().UTC() + } + return signal.IsLiveSignalFresh(v, at) && + !v.Timestamp.After(at.Add(signal.LiveSignalFreshnessThreshold)) +} + +func firstStoredSignal(store *signal.Store, vehicleID int64, keys ...string) *signal.Value { + if store == nil { + return nil + } + for _, key := range keys { + if value := store.Get(vehicleID, key); value != nil { + return value + } + } + return nil +} + +// chargeLocationIsFresh confirms that the coordinates resolved for a charge +// came from the current telemetry window. Auto-discovery must never act on a +// forward-folded location from a prior drive. +func (t *TelemetrySessionTracker) chargeLocationIsFresh( + vehicleID int64, + signals map[string]interface{}, + fieldTs map[string]time.Time, + payloadTs, at time.Time, +) bool { + if _, _, ok := signalLatLon(signals); ok { + observedAt := time.Time{} + for _, key := range []string{ + "Location", + "LocationLatitude", + "LocationLongitude", + "Latitude", + "Longitude", + } { + if ts := fieldTs[key]; !ts.IsZero() && + (observedAt.IsZero() || ts.Before(observedAt)) { + observedAt = ts + } + } + if observedAt.IsZero() { + observedAt = payloadTs + } + if observedAt.IsZero() { + observedAt = at + } + return freshChargeCoordinateValue(&signal.Value{Timestamp: observedAt}, at) + } + + latValue := firstStoredSignal(t.localSignals, vehicleID, "LocationLatitude", "Latitude") + lonValue := firstStoredSignal(t.localSignals, vehicleID, "LocationLongitude", "Longitude") + return freshChargeCoordinateValue(latValue, at) && + freshChargeCoordinateValue(lonValue, at) +} + func (t *TelemetrySessionTracker) trackCharging(ctx context.Context, vehicleID int64, vin string, signals map[string]interface{}, accumulatedSignals map[string]interface{}, payloadTs time.Time, fieldTs map[string]time.Time) { chargeState, hasChargeState := signalStr(signals, "DetailedChargeState", "ChargeState") if !hasChargeState { @@ -165,12 +223,18 @@ func (t *TelemetrySessionTracker) trackCharging(ctx context.Context, vehicleID i startTs = payloadTs } startTs = eventTimeOrNow(startTs) + locationFresh := hasLoc && + t.chargeLocationIsFresh(vehicleID, signals, fieldTs, payloadTs, startTs) session := &chargingmodel.ChargingSession{ VehicleID: vehicleID, StartedAt: startTs, StartSocPct: floatPtr(float64(batteryLevel)), } + if locationFresh { + session.StartLat = floatPtr(lat) + session.StartLng = floatPtr(lon) + } if err := t.chargeRepo.Create(ctx, session); err != nil { log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("telemetry: failed to create charge session") @@ -190,6 +254,7 @@ func (t *TelemetrySessionTracker) trackCharging(ctx context.Context, vehicleID i if hasLoc { sc.Latitude = floatPtr(lat) sc.Longitude = floatPtr(lon) + sc.LocationFresh = locationFresh } if startRange > 0 { sc.StartRangeKm = floatPtr(startRange) @@ -198,6 +263,23 @@ func (t *TelemetrySessionTracker) trackCharging(ctx context.Context, vehicleID i t.activeCharges[vehicleID] = sc metrics.ChargeSessionsActive.Inc() + // Surface a newly discovered charging place as soon as a confirmed + // charge starts. The same idempotent routine runs again at completion + // once energy is known, when it can also calculate the session cost. + if sc.LocationFresh && t.geofenceRepo != nil { + sessionID, startLat, startLon := sc.SessionID, *sc.Latitude, *sc.Longitude + safeGo("charge_geofence_discovery", func() { + t.applyGeofencePricingAsync( + sessionID, + vehicleID, + startLat, + startLon, + startTs, + map[string]interface{}{}, + ) + }) + } + // Accumulate and flush first reading immediately sc.accumulatedSignals = accumulateSignals(sc.accumulatedSignals, signals) t.flushChargeTelemetry(ctx, sc) @@ -211,6 +293,18 @@ func (t *TelemetrySessionTracker) trackCharging(ctx context.Context, vehicleID i } else if isCharging && hasCharge { // === UPDATE ACTIVE CHARGE === active.LastSeen = time.Now().UTC() + if lat, lon, ok := t.resolveLatLon(vehicleID, signals, accumulatedSignals); ok && + t.chargeLocationIsFresh( + vehicleID, + signals, + fieldTs, + payloadTs, + eventTimeOrNow(payloadTs), + ) { + active.Latitude = floatPtr(lat) + active.Longitude = floatPtr(lon) + active.LocationFresh = true + } // Track energy if ea, ok := signalFloat(signals, "DCChargingEnergyIn"); ok { @@ -265,6 +359,18 @@ func (t *TelemetrySessionTracker) trackCharging(ctx context.Context, vehicleID i } else if !isCharging && hasCharge { // === CHARGE ENDED === + if lat, lon, ok := t.resolveLatLon(vehicleID, signals, accumulatedSignals); ok && + t.chargeLocationIsFresh( + vehicleID, + signals, + fieldTs, + payloadTs, + eventTimeOrNow(payloadTs), + ) { + active.Latitude = floatPtr(lat) + active.Longitude = floatPtr(lon) + active.LocationFresh = true + } t.completeChargeLocked(ctx, vehicleID, active, signals, payloadTs) } } @@ -511,9 +617,16 @@ func (t *TelemetrySessionTracker) completeChargeLocked(ctx context.Context, vehi } } - // Default cost_currency — will be overridden when geofence electricity pricing is implemented - // TODO: Set from geofence.ElectricityCurrency when Geofence model gains that field - enhancedFields["cost_currency"] = "USD" + // cost_currency / cost_decimal / geofence_id / rate_id / cost_source are + // intentionally NOT defaulted here. Charging-place pricing (see + // applyGeofencePricingAsync below) resolves/creates the session's + // geofence and looks up its rate keyed on StartTime — that can only + // happen after this function knows a location, and per the hot-path + // rule it must never block synchronous charge completion, so it always + // runs in the async leg alongside the existing place-name resolution. A + // session with no location, or a geofence with no rate configured yet, + // is left with cost_source unset (implicitly "unknown") rather than a + // fabricated currency — see repriceEligibleCostSources precedence. if err := t.db.WithTx(ctx, func(tx pgx.Tx) error { var endSocPct *float64 @@ -590,57 +703,31 @@ func (t *TelemetrySessionTracker) completeChargeLocked(ctx context.Context, vehi } } - // TODO: Auto-calculate charge cost from geofence electricity rate. - // Geofence model does not yet have ElectricityRate/ElectricityCurrency fields. - // When added, compute: cost_decimal = energyAddedWh * ratePerWh, cost_currency = geofence.ElectricityCurrency. + // Geofence attribution + rate-based cost calculation happen in the + // async leg below (applyGeofencePricingAsync) — never inside this + // transaction: discovery involves an advisory-lock round trip and + // must not add latency/failure surface to charge completion itself. return nil }); err != nil { log.Error().Err(err).Int64("session_id", active.SessionID).Msg("telemetry: failed to complete charge") } - // Resolve location name async — geocoding stays outside the transaction - if len(enhancedFields) > 0 && active.Latitude != nil && active.Longitude != nil { + // Resolve place name + charging-place geofence/rate attribution async — + // geocoding, discovery, and pricing all stay outside the completion + // transaction. Guarded on location alone (NOT len(enhancedFields) > 0 — + // removing the old blanket cost_currency default made enhancedFields + // legitimately empty on many sessions, and we must still attempt place + // resolution + geofence pricing whenever coordinates are available). + if active.LocationFresh && active.Latitude != nil && active.Longitude != nil { fieldsCopy := make(map[string]interface{}, len(enhancedFields)+1) for k, v := range enhancedFields { fieldsCopy[k] = v } - go func(sessionID int64, lat, lon float64, fields map[string]interface{}) { - gctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - // 1. Check geofences first (user-defined name) - if geofences, err := t.geofenceRepo.FindByCoordinates(gctx, lat, lon); err == nil && len(geofences) > 0 { - fields["start_place"] = geofences[0].Name - _ = t.chargeRepo.PartialUpdate(gctx, sessionID, fields) - return - } - - // 2. Check places cache (previously resolved, within 50m) - if cached, err := t.placesCache.FindNearby(gctx, lat, lon, 50); err == nil && cached != nil { - _ = t.placesCache.IncrementHitCount(gctx, cached.ID) - fields["start_place"] = cached.DisplayName - _ = t.chargeRepo.PartialUpdate(gctx, sessionID, fields) - return - } - - // 3. Reverse geocode and cache the result - result, err := t.geocoder.ReverseGeocode(gctx, lat, lon) - if err != nil { - _ = t.chargeRepo.PartialUpdate(gctx, sessionID, fields) - return - } - name := result.ShortName() - fields["start_place"] = name - _ = t.chargeRepo.PartialUpdate(gctx, sessionID, fields) - - // Save to cache - _ = t.placesCache.Upsert(gctx, &dbadmin.PlaceCacheEntry{ - Latitude: lat, Longitude: lon, DisplayName: name, Source: "geocoding", - City: ptrStrOrNil(result.City), State: ptrStrOrNil(result.State), - Country: ptrStrOrNil(result.Country), Postcode: ptrStrOrNil(result.PostCode), - }) - }(active.SessionID, *active.Latitude, *active.Longitude, fieldsCopy) + sessionID, lat, lon, startedAt := active.SessionID, *active.Latitude, *active.Longitude, active.StartTime + safeGo("charge_geofence_pricing", func() { + t.applyGeofencePricingAsync(sessionID, vehicleID, lat, lon, startedAt, fieldsCopy) + }) } log.Info().Int64("vehicle_id", vehicleID).Int64("session_id", active.SessionID). diff --git a/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go b/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go index f7e2f95bda..788cc70a3e 100644 --- a/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go +++ b/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go @@ -132,7 +132,7 @@ func TestChargeTracking_StartSnapshot_UsesState(t *testing.T) { startTime := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) wantSnap := signal.State{ "BatteryLevel": 18.0, - "ACChargingEnergyIn": 100.0, + "ACChargingEnergyIn": 100000.0, "BatteryRange": 95.0, } @@ -201,9 +201,9 @@ func TestChargeTracking_EndSnapshot_UsesState(t *testing.T) { // Distinct payloads so the test can confirm the test-side // caller plumbed each at-anchor through to the right call. if at.Equal(startTime) { - return signal.State{"ACChargingEnergyIn": 100.0, "BatteryRange": 95.0}, nil + return signal.State{"ACChargingEnergyIn": 100000.0, "BatteryRange": 95.0}, nil } - return signal.State{"ACChargingEnergyIn": 130.5, "BatteryRange": 215.0}, nil + return signal.State{"ACChargingEnergyIn": 130500.0, "BatteryRange": 215.0}, nil }, } tracker := &TelemetrySessionTracker{} @@ -247,8 +247,8 @@ func TestChargeTracking_EndSnapshot_UsesState(t *testing.T) { // would yield a negative delta the production code drops. startEnergy, _ := snapFloat(stateToLegacyMap(startSnap), "ACChargingEnergyIn") endEnergy, _ := snapFloat(stateToLegacyMap(endSnap), "ACChargingEnergyIn") - if delta := endEnergy - startEnergy; delta != 30.5 { - t.Fatalf("energy delta from snapshots = %v, want 30.5", delta) + if delta := endEnergy - startEnergy; delta != 30500 { + t.Fatalf("energy delta from snapshots = %v, want 30500 Wh", delta) } } diff --git a/internal/api/telemetry/telemetry_sessions_drive_geofence_attachment_test.go b/internal/api/telemetry/telemetry_sessions_drive_geofence_attachment_test.go new file mode 100644 index 0000000000..5f75192929 --- /dev/null +++ b/internal/api/telemetry/telemetry_sessions_drive_geofence_attachment_test.go @@ -0,0 +1,155 @@ +package telemetry + +import ( + "context" + "errors" + "testing" + + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/ev-dev-labs/teslasync/internal/geocoding" +) + +// ============================================================================= +// telemetry_sessions_drive_geofence_attachment_test.go — tracker-layer tests +// for resolveAndUpdateAddress's geofence attachment behavior (migration +// 000228_geofence_charging_place_pricing). +// +// Business requirement #5 ("drive start/end naming... existing place text +// retained as fallback") and the auto-discovery design's explicit "match vs +// auto-create" split are pinned here: +// - A drive endpoint that falls inside an existing geofence attaches that +// geofence's id (start_geofence_id/end_geofence_id) AND uses its name +// for start_place/end_place, same as before this feature except for the +// new id attachment. +// - A drive endpoint NEVER auto-creates a geofence — that is exclusive to +// a confirmed charging session (see +// telemetry_sessions_charge_geofence_pricing_test.go). No match simply +// falls through to the pre-existing places-cache/geocoder resolution +// chain, exactly as before this feature. +// +// Reuses this package's existing openGeofencePricingDB/seedPricingGeofence/ +// countGeofences helpers (telemetry_sessions_charge_geofence_pricing_test.go) +// and the stubGeocoder type (place_label_repair_test.go). +// ============================================================================= + +const ( + driveGeofenceLat = 15.4000 + driveGeofenceLon = -48.4000 + driveNoMatchLat = 16.5000 + driveNoMatchLon = -49.5000 +) + +// errDriveGeocoderMustNotBeCalled documents intent: a stubGeocoder wired +// with this error makes "the geocoder must never be reached" an explicit, +// named failure mode instead of an unexplained generic error string. +var errDriveGeocoderMustNotBeCalled = errors.New("geocoder must not be called when an existing geofence already matched") + +// seedDriveRow inserts one bare drives row (no place/coords yet) for +// vehicleID, removed via t.Cleanup. +func seedDriveRow(t *testing.T, db *database.DB, vehicleID int64) int64 { + t.Helper() + var id int64 + err := db.Pool.QueryRow(context.Background(), + `INSERT INTO drives (vehicle_id, started_at) VALUES ($1, now()) RETURNING id`, vehicleID).Scan(&id) + if err != nil { + t.Fatalf("seed drive: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM drives WHERE id = $1`, id) + }) + return id +} + +// geocoderWithResult returns a stubGeocoder that answers exactly one +// coordinate with a named result, using ShortName()'s Name-preferred branch. +func geocoderWithResult(lat, lon float64, name string) *stubGeocoder { + return &stubGeocoder{results: map[string]*geocoding.GeoResult{ + coordKey(lat, lon): {Name: name}, + }} +} + +// readDriveGeofenceFields reads back one place/geofence-id column pair +// (start_place+start_geofence_id, or end_place+end_geofence_id) for a drive. +func readDriveGeofenceFields(t *testing.T, db *database.DB, driveID int64, placeCol, geofenceCol string) (*string, *int64) { + t.Helper() + var place *string + var geofenceID *int64 + query := `SELECT ` + placeCol + `, ` + geofenceCol + ` FROM drives WHERE id = $1` + if err := db.Pool.QueryRow(context.Background(), query, driveID).Scan(&place, &geofenceID); err != nil { + t.Fatalf("read drive %d (%s, %s): %v", driveID, placeCol, geofenceCol, err) + } + return place, geofenceID +} + +// TestResolveAndUpdateAddress_MatchesGeofence_SetsPlaceAndGeofenceID proves +// the match half of "match vs auto-create" at the tracker layer: a drive +// endpoint whose coordinates fall inside an existing geofence gets both its +// place text AND its new start_geofence_id/end_geofence_id column set from +// that match — never from the places cache or geocoder (which the stub +// below would fail loudly if reached, since it returns an error). +func TestResolveAndUpdateAddress_MatchesGeofence_SetsPlaceAndGeofenceID(t *testing.T) { + db := openGeofencePricingDB(t) + geofenceID := seedPricingGeofence(t, db, driveGeofenceLat, driveGeofenceLon, "Matched Drive Endpoint Place") + + startDriveID := seedDriveRow(t, db, 820001) + endDriveID := seedDriveRow(t, db, 820002) + + geo := &stubGeocoder{err: errDriveGeocoderMustNotBeCalled} + tracker := NewTelemetrySessionTracker(db, nil, geo, nil) + + if ok := tracker.resolveAndUpdateAddress(startDriveID, driveGeofenceLat, driveGeofenceLon, true, resolveUseCache); !ok { + t.Fatalf("resolveAndUpdateAddress(start) = false, want true (geofence match)") + } + if ok := tracker.resolveAndUpdateAddress(endDriveID, driveGeofenceLat, driveGeofenceLon, false, resolveUseCache); !ok { + t.Fatalf("resolveAndUpdateAddress(end) = false, want true (geofence match)") + } + + startPlace, startGeofenceID := readDriveGeofenceFields(t, db, startDriveID, "start_place", "start_geofence_id") + if startPlace == nil || *startPlace != "Matched Drive Endpoint Place" { + t.Fatalf("start_place = %v, want the matched geofence's name", startPlace) + } + if startGeofenceID == nil || *startGeofenceID != geofenceID { + t.Fatalf("start_geofence_id = %v, want %d", startGeofenceID, geofenceID) + } + + endPlace, endGeofenceID := readDriveGeofenceFields(t, db, endDriveID, "end_place", "end_geofence_id") + if endPlace == nil || *endPlace != "Matched Drive Endpoint Place" { + t.Fatalf("end_place = %v, want the matched geofence's name", endPlace) + } + if endGeofenceID == nil || *endGeofenceID != geofenceID { + t.Fatalf("end_geofence_id = %v, want %d", endGeofenceID, geofenceID) + } +} + +// TestResolveAndUpdateAddress_NoMatch_NeverCreatesGeofence proves the other +// half: with no existing geofence at these coordinates, the function must +// (a) leave start_geofence_id/end_geofence_id nil, (b) never insert a new +// geofence row (auto-create is exclusive to confirmed charging sessions), +// and (c) still fall through to the pre-existing geocoder resolution chain +// so place naming keeps working exactly as it did before this feature. +func TestResolveAndUpdateAddress_NoMatch_NeverCreatesGeofence(t *testing.T) { + db := openGeofencePricingDB(t) + driveID := seedDriveRow(t, db, 820003) + + geo := geocoderWithResult(driveNoMatchLat, driveNoMatchLon, "Fallback Geocoded Place") + tracker := NewTelemetrySessionTracker(db, nil, geo, nil) + + before := countGeofences(t, db) + ok := tracker.resolveAndUpdateAddress(driveID, driveNoMatchLat, driveNoMatchLon, true, resolveUseCache) + after := countGeofences(t, db) + + if !ok { + t.Fatalf("resolveAndUpdateAddress = false, want true (geocoder fallback succeeds)") + } + if after != before { + t.Fatalf("geofence count = %d -> %d, want unchanged (drive endpoints never auto-create)", before, after) + } + + startPlace, startGeofenceID := readDriveGeofenceFields(t, db, driveID, "start_place", "start_geofence_id") + if startGeofenceID != nil { + t.Fatalf("start_geofence_id = %v, want nil (no geofence matched)", startGeofenceID) + } + if startPlace == nil || *startPlace != "Fallback Geocoded Place" { + t.Fatalf("start_place = %v, want the geocoder's fallback name", startPlace) + } +} diff --git a/internal/api/telemetry/telemetry_sessions_drive_tracking.go b/internal/api/telemetry/telemetry_sessions_drive_tracking.go index c9063f606f..5c0e5dbfc4 100644 --- a/internal/api/telemetry/telemetry_sessions_drive_tracking.go +++ b/internal/api/telemetry/telemetry_sessions_drive_tracking.go @@ -1364,13 +1364,26 @@ func (t *TelemetrySessionTracker) resolveAndUpdateAddress(driveID int64, lat, lo // skips fields not in the allowlist, leaving end_place permanently NULL // and the Visited Locations page empty. field := "end_place" + geofenceField := "end_geofence_id" if isStart { field = "start_place" - } - - // 1. Check geofences first (user-defined names like "Home", "Office") + geofenceField = "start_geofence_id" + } + + // 1. Check geofences first (user-defined names like "Home", "Office"). + // Match-only — a drive endpoint NEVER auto-creates a geofence (that is + // exclusive to a confirmed charging session; see + // telemetry_sessions_charge_geofence_pricing.go). Attaching + // start_geofence_id/end_geofence_id lets a later place rename + // retroactively improve this drive's displayed name — see + // DriveRepo.resolveCurrentGeofenceNames and the StartGeofenceID doc on + // drivemodel.Drive — while start_place/end_place keep storing today's + // name as the permanent fallback. if geofences, err := t.geofenceRepo.FindByCoordinates(ctx, lat, lon); err == nil && len(geofences) > 0 { - _ = t.driveRepo.PartialUpdate(ctx, driveID, map[string]interface{}{field: geofences[0].Name}) + _ = t.driveRepo.PartialUpdate(ctx, driveID, map[string]interface{}{ + field: geofences[0].Name, + geofenceField: geofences[0].ID, + }) metrics.GeocodingTotal.WithLabelValues("geofence").Inc() return true } diff --git a/internal/api/telemetry/telemetry_sessions_recovery_test.go b/internal/api/telemetry/telemetry_sessions_recovery_test.go index c1a5bd9805..6c91fd58af 100644 --- a/internal/api/telemetry/telemetry_sessions_recovery_test.go +++ b/internal/api/telemetry/telemetry_sessions_recovery_test.go @@ -132,13 +132,13 @@ func TestRecovery_RebuildsStateFromForwardFold(t *testing.T) { case vehicleID == chargeVehicleID && at.Equal(chargeStart): return signal.State{ "BatteryLevel": 18.0, - "ACChargingEnergyIn": 100.0, + "ACChargingEnergyIn": 100000.0, "BatteryRange": 95.0, }, nil case vehicleID == chargeVehicleID && at.Equal(chargeEnd): return signal.State{ "BatteryLevel": 80.0, - "ACChargingEnergyIn": 130.5, + "ACChargingEnergyIn": 130500.0, "BatteryRange": 215.0, }, nil } @@ -233,19 +233,19 @@ func TestRecovery_RebuildsStateFromForwardFold(t *testing.T) { // Charge energy delta: a regression that swapped start/end would // yield a negative delta the production code drops on the > 0 guard, - // silently dropping every recovered charge's energy_added_kwh. + // silently dropping every recovered charge's energy total. chargeStartLegacy := stateToLegacyMap(chargeStartSnap) chargeEndLegacy := stateToLegacyMap(chargeEndSnap) startEnergy, ok := snapFloat(chargeStartLegacy, "ACChargingEnergyIn") - if !ok || startEnergy != 100.0 { - t.Fatalf("charge start ACChargingEnergyIn = (%v, %v), want (100.0, true)", startEnergy, ok) + if !ok || startEnergy != 100000.0 { + t.Fatalf("charge start ACChargingEnergyIn = (%v, %v), want (100000.0, true)", startEnergy, ok) } endEnergy, ok := snapFloat(chargeEndLegacy, "ACChargingEnergyIn") - if !ok || endEnergy != 130.5 { - t.Fatalf("charge end ACChargingEnergyIn = (%v, %v), want (130.5, true)", endEnergy, ok) + if !ok || endEnergy != 130500.0 { + t.Fatalf("charge end ACChargingEnergyIn = (%v, %v), want (130500.0, true)", endEnergy, ok) } - if delta := endEnergy - startEnergy; delta != 30.5 { - t.Fatalf("charge energy delta from snapshots = %v, want 30.5", delta) + if delta := endEnergy - startEnergy; delta != 30500 { + t.Fatalf("charge energy delta from snapshots = %v, want 30500 Wh", delta) } // Nil-state degradation: when SetDriveStateReader has not run yet diff --git a/internal/api/vehicleinfo/handler.go b/internal/api/vehicleinfo/handler.go index fe9195ca66..61b74179bf 100644 --- a/internal/api/vehicleinfo/handler.go +++ b/internal/api/vehicleinfo/handler.go @@ -1,9 +1,12 @@ package vehicleinfo import ( + "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "net/http" "time" @@ -17,6 +20,13 @@ import ( "github.com/ev-dev-labs/teslasync/internal/tesla" "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" +) + +const ( + maxOpaqueRequestBodyBytes int64 = 16 * 1024 + teslaInfoRequestTimeout = 30 * time.Second ) // teslaInfoClient is the slice of *tesla.Client the handler needs to fetch @@ -30,7 +40,10 @@ type teslaInfoClient interface { GetVehicleSpecs(ctx context.Context, vin string) ([]byte, int, error) GetSubscriptionEligibility(ctx context.Context, vin string) ([]byte, int, error) GetUpgradeEligibility(ctx context.Context, vin string) ([]byte, int, error) - GetWarrantyDetails(ctx context.Context) ([]byte, int, error) + GetWarrantyDetails(ctx context.Context, vin string) ([]byte, int, error) + GetVehiclePricing(ctx context.Context, payload tesla.JSONRequestObject) ([]byte, int, error) + GetEnterpriseRoles(ctx context.Context, vin string) ([]byte, int, error) + SetEnterprisePayer(ctx context.Context, vin string, payload tesla.JSONRequestObject) ([]byte, int, error) } // userConfigStore is the subset of *tesladb.TeslaUserConfigRepo used to read @@ -69,6 +82,56 @@ type vehicleInfoEnvelope struct { FetchedAt *string `json:"fetched_at"` } +type operationResultEnvelope struct { + Data json.RawMessage `json:"data"` +} + +type vehiclePricingRequest struct { + Payload tesla.JSONRequestObject `json:"payload"` +} + +type enterprisePayerRequest struct { + Payload tesla.JSONRequestObject `json:"payload"` + Confirmed bool `json:"confirmed"` +} + +type paidRequest struct { + Confirmed bool `json:"confirmed"` +} + +type tokenRequirement uint8 + +const ( + userTokenRequired tokenRequirement = iota + partnerTokenRequired +) + +type requestValidationError struct { + status int + message string +} + +func startHandlerSpan(r *http.Request, name string) (*http.Request, trace.Span) { + ctx, span := otel.Tracer("api").Start(r.Context(), name) + return r.WithContext(ctx), span +} + +func traceID(ctx context.Context) string { + spanContext := trace.SpanContextFromContext(ctx) + if !spanContext.IsValid() { + return "" + } + return spanContext.TraceID().String() +} + +func localVehicleID(r *http.Request) int64 { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil { + return 0 + } + return vehicleID +} + // resolveVIN maps the {vehicleID} URL param to the vehicle's VIN and the // HTTP status a caller should surface on failure: // @@ -103,7 +166,11 @@ func (h *Handler) resolveVINOrWriteError(w http.ResponseWriter, r *http.Request) case http.StatusNotFound: httpx.WriteError(w, status, "vehicle not found") case http.StatusInternalServerError: - log.Error().Err(err).Msg("failed to resolve vehicle for vehicle-info endpoint") + trace.SpanFromContext(r.Context()).RecordError(err) + log.Error(). + Err(err). + Str("trace_id", traceID(r.Context())). + Msg("failed to resolve vehicle for vehicle-info endpoint") httpx.WriteError(w, status, "failed to resolve vehicle") default: httpx.WriteError(w, status, "invalid vehicle ID") @@ -118,24 +185,30 @@ func (h *Handler) resolveVINOrWriteError(w http.ResponseWriter, r *http.Request) // MobileEnabled returns stored mobile_enabled status from DB. // GET /api/v1/vehicles/{vehicleID}/mobile-enabled func (h *Handler) MobileEnabled(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.mobile_enabled") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } - h.getVehicleConfig(w, r, "mobile_enabled:"+vin) + h.getVehicleConfig(w, r, "mobile_enabled:"+vin, "mobile_enabled", localVehicleID(r)) } // RefreshMobileEnabled fetches mobile_enabled from Tesla and saves to DB. // POST /api/v1/vehicles/{vehicleID}/mobile-enabled/refresh func (h *Handler) RefreshMobileEnabled(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.refresh_mobile_enabled") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } configKey := "mobile_enabled:" + vin - h.refreshVehicleConfig(w, r, configKey, "mobile_enabled", vin, func() ([]byte, int, error) { - return h.teslaClient.GetMobileEnabled(r.Context(), vin) - }, false) + h.refreshVehicleConfig(w, r, configKey, "mobile_enabled", localVehicleID(r), func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetMobileEnabled(ctx, vin) + }, userTokenRequired, false) } // ---------- Vehicle Options ---------- @@ -143,24 +216,30 @@ func (h *Handler) RefreshMobileEnabled(w http.ResponseWriter, r *http.Request) { // VehicleOptions returns stored option codes from DB. // GET /api/v1/vehicles/{vehicleID}/options func (h *Handler) VehicleOptions(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.options") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } - h.getVehicleConfig(w, r, "vehicle_options:"+vin) + h.getVehicleConfig(w, r, "vehicle_options:"+vin, "vehicle_options", localVehicleID(r)) } // RefreshVehicleOptions fetches options from Tesla and saves to DB. // POST /api/v1/vehicles/{vehicleID}/options/refresh func (h *Handler) RefreshVehicleOptions(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.refresh_options") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } configKey := "vehicle_options:" + vin - h.refreshVehicleConfig(w, r, configKey, "vehicle_options", vin, func() ([]byte, int, error) { - return h.teslaClient.GetVehicleOptions(r.Context(), vin) - }, false) + h.refreshVehicleConfig(w, r, configKey, "vehicle_options", localVehicleID(r), func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetVehicleOptions(ctx, vin) + }, userTokenRequired, false) } // ---------- Vehicle Specs ---------- @@ -168,11 +247,14 @@ func (h *Handler) RefreshVehicleOptions(w http.ResponseWriter, r *http.Request) // VehicleSpecs returns stored specs from DB. // GET /api/v1/vehicles/{vehicleID}/specs func (h *Handler) VehicleSpecs(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.specs") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } - h.getVehicleConfig(w, r, "vehicle_specs:"+vin) + h.getVehicleConfig(w, r, "vehicle_specs:"+vin, "vehicle_specs", localVehicleID(r)) } // RefreshVehicleSpecs fetches specs from Tesla using a partner token and saves to DB. @@ -180,33 +262,53 @@ func (h *Handler) VehicleSpecs(w http.ResponseWriter, r *http.Request) { // redundant calls if specs were already fetched within the last 24 hours. // POST /api/v1/vehicles/{vehicleID}/specs/refresh func (h *Handler) RefreshVehicleSpecs(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.refresh_specs") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } + + var request paidRequest + if validationErr := decodeLimitedJSON(w, r, &request); validationErr != nil { + httpx.WriteError(w, validationErr.status, validationErr.message) + return + } + if !request.Confirmed { + httpx.WriteError(w, http.StatusBadRequest, "explicit confirmation is required for this paid Tesla request") + return + } + + vehicleID := localVehicleID(r) configKey := "vehicle_specs:" + vin // Freshness guard: reject if already fetched within 24 hours existing, err := h.configRepo.GetByType(r.Context(), configKey) if err != nil { - log.Error().Err(err).Str("config_key", configKey).Msg("failed to check specs freshness") + span.RecordError(errors.New("vehicle specs freshness lookup failed")) + log.Error(). + Str("trace_id", traceID(r.Context())). + Int64("vehicle_id", vehicleID). + Str("operation", "vehicle_specs"). + Msg("failed to check specs freshness") httpx.WriteError(w, http.StatusInternalServerError, "failed to check specs freshness") return } if existing != nil && time.Since(existing.FetchedAt) < 24*time.Hour { log.Warn(). - Str("vin", vin). + Int64("vehicle_id", vehicleID). Time("fetched_at", existing.FetchedAt). Msg("vehicle specs refresh rejected — already fetched within 24 hours (costs $0.10/call)") httpx.WriteError(w, http.StatusTooManyRequests, "specs were already fetched within the last 24 hours — this endpoint costs $0.10 per call") return } - log.Warn().Str("vin", vin).Msg("refreshing vehicle specs from Tesla — this call costs $0.10") + log.Warn().Int64("vehicle_id", vehicleID).Msg("refreshing vehicle specs from Tesla — this call costs $0.10") - h.refreshVehicleConfig(w, r, configKey, "vehicle_specs", vin, func() ([]byte, int, error) { - return h.teslaClient.GetVehicleSpecs(r.Context(), vin) - }, true) + h.refreshVehicleConfig(w, r, configKey, "vehicle_specs", vehicleID, func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetVehicleSpecs(ctx, vin) + }, partnerTokenRequired, true) } // ---------- Subscription Eligibility ---------- @@ -214,24 +316,30 @@ func (h *Handler) RefreshVehicleSpecs(w http.ResponseWriter, r *http.Request) { // SubscriptionEligibility returns stored subscription eligibility from DB. // GET /api/v1/vehicles/{vehicleID}/subscriptions func (h *Handler) SubscriptionEligibility(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.subscriptions") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } - h.getVehicleConfig(w, r, "subscriptions:"+vin) + h.getVehicleConfig(w, r, "subscriptions:"+vin, "subscriptions", localVehicleID(r)) } // RefreshSubscriptionEligibility fetches subscription eligibility from Tesla and saves to DB. // POST /api/v1/vehicles/{vehicleID}/subscriptions/refresh func (h *Handler) RefreshSubscriptionEligibility(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.refresh_subscriptions") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } configKey := "subscriptions:" + vin - h.refreshVehicleConfig(w, r, configKey, "subscriptions", vin, func() ([]byte, int, error) { - return h.teslaClient.GetSubscriptionEligibility(r.Context(), vin) - }, false) + h.refreshVehicleConfig(w, r, configKey, "subscriptions", localVehicleID(r), func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetSubscriptionEligibility(ctx, vin) + }, userTokenRequired, false) } // ---------- Upgrade Eligibility ---------- @@ -239,50 +347,186 @@ func (h *Handler) RefreshSubscriptionEligibility(w http.ResponseWriter, r *http. // UpgradeEligibility returns stored upgrade eligibility from DB. // GET /api/v1/vehicles/{vehicleID}/upgrades func (h *Handler) UpgradeEligibility(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.upgrades") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } - h.getVehicleConfig(w, r, "upgrades:"+vin) + h.getVehicleConfig(w, r, "upgrades:"+vin, "upgrades", localVehicleID(r)) } // RefreshUpgradeEligibility fetches upgrade eligibility from Tesla and saves to DB. // POST /api/v1/vehicles/{vehicleID}/upgrades/refresh func (h *Handler) RefreshUpgradeEligibility(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_info.refresh_upgrades") + defer span.End() + vin, ok := h.resolveVINOrWriteError(w, r) if !ok { return } configKey := "upgrades:" + vin - h.refreshVehicleConfig(w, r, configKey, "upgrades", vin, func() ([]byte, int, error) { - return h.teslaClient.GetUpgradeEligibility(r.Context(), vin) - }, false) + h.refreshVehicleConfig(w, r, configKey, "upgrades", localVehicleID(r), func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetUpgradeEligibility(ctx, vin) + }, userTokenRequired, false) } // ---------- Warranty Details ---------- -// WarrantyDetails returns stored warranty details from DB. -// GET /api/v1/tesla/warranty +// WarrantyDetails returns stored warranty details for a vehicle from DB. +// GET /api/v1/vehicles/{vehicleID}/warranty func (h *Handler) WarrantyDetails(w http.ResponseWriter, r *http.Request) { - h.getVehicleConfig(w, r, "warranty") + r, span := startHandlerSpan(r, "api.vehicle_info.warranty") + defer span.End() + + vin, ok := h.resolveVINOrWriteError(w, r) + if !ok { + return + } + h.getVehicleConfig(w, r, "warranty:"+vin, "warranty", localVehicleID(r)) } -// RefreshWarrantyDetails fetches warranty details from Tesla and saves to DB. -// POST /api/v1/tesla/warranty/refresh +// RefreshWarrantyDetails fetches VIN-scoped warranty details from Tesla and saves to DB. +// POST /api/v1/vehicles/{vehicleID}/warranty/refresh func (h *Handler) RefreshWarrantyDetails(w http.ResponseWriter, r *http.Request) { - configKey := "warranty" - h.refreshVehicleConfig(w, r, configKey, "warranty", "", func() ([]byte, int, error) { - return h.teslaClient.GetWarrantyDetails(r.Context()) - }, false) + r, span := startHandlerSpan(r, "api.vehicle_info.refresh_warranty") + defer span.End() + + vin, ok := h.resolveVINOrWriteError(w, r) + if !ok { + return + } + configKey := "warranty:" + vin + h.refreshVehicleConfig(w, r, configKey, "warranty", localVehicleID(r), func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetWarrantyDetails(ctx, vin) + }, userTokenRequired, false) +} + +// ---------- Vehicle Pricing ---------- + +// VehiclePricing forwards a validated opaque JSON object to Tesla's +// read-only pricing query. The undocumented request object is never persisted. +// POST /api/v1/tesla/vehicle-pricing +func (h *Handler) VehiclePricing(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_management.pricing") + defer span.End() + + var req vehiclePricingRequest + if validationErr := decodeLimitedJSON(w, r, &req); validationErr != nil { + httpx.WriteError(w, validationErr.status, validationErr.message) + return + } + if len(req.Payload) == 0 { + httpx.WriteError(w, http.StatusBadRequest, "payload must be a non-empty JSON object") + return + } + + h.executePartnerOperation(w, r, "vehicle_pricing", 0, func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetVehiclePricing(ctx, req.Payload) + }) +} + +// ---------- Enterprise Roles ---------- + +// EnterpriseRoles returns cached enterprise roles for the selected vehicle. +// GET /api/v1/vehicles/{vehicleID}/enterprise-roles +func (h *Handler) EnterpriseRoles(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_management.enterprise_roles") + defer span.End() + + vin, ok := h.resolveVINOrWriteError(w, r) + if !ok { + return + } + h.getVehicleConfig( + w, + r, + "enterprise_roles:"+vin, + "enterprise_roles", + localVehicleID(r), + ) +} + +// RefreshEnterpriseRoles fetches enterprise roles from Tesla and stores only +// the response under the existing per-VIN tesla_user_config convention. +// POST /api/v1/vehicles/{vehicleID}/enterprise-roles/refresh +func (h *Handler) RefreshEnterpriseRoles(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_management.refresh_enterprise_roles") + defer span.End() + + vin, ok := h.resolveVINOrWriteError(w, r) + if !ok { + return + } + h.refreshVehicleConfig( + w, + r, + "enterprise_roles:"+vin, + "enterprise_roles", + localVehicleID(r), + func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.GetEnterpriseRoles(ctx, vin) + }, + partnerTokenRequired, + false, + ) +} + +// EnterprisePayer changes enterprise billing responsibility. The explicit +// wrapper confirmation is checked before any Fleet API call, and neither the +// request nor response is persisted. +// POST /api/v1/vehicles/{vehicleID}/enterprise-payer +func (h *Handler) EnterprisePayer(w http.ResponseWriter, r *http.Request) { + r, span := startHandlerSpan(r, "api.vehicle_management.enterprise_payer") + defer span.End() + + vin, ok := h.resolveVINOrWriteError(w, r) + if !ok { + return + } + vehicleID := localVehicleID(r) + + var req enterprisePayerRequest + if validationErr := decodeLimitedJSON(w, r, &req); validationErr != nil { + httpx.WriteError(w, validationErr.status, validationErr.message) + return + } + if !req.Confirmed { + httpx.WriteError(w, http.StatusPreconditionFailed, "explicit payer change confirmation is required") + return + } + if len(req.Payload) == 0 { + httpx.WriteError(w, http.StatusBadRequest, "payload must be a non-empty JSON object") + return + } + + h.executePartnerOperation(w, r, "enterprise_payer", vehicleID, func(ctx context.Context) ([]byte, int, error) { + return h.teslaClient.SetEnterprisePayer(ctx, vin, req.Payload) + }) } // ---------- Shared helpers ---------- -// getVehicleConfig returns stored per-vehicle config data with fetched_at metadata. -func (h *Handler) getVehicleConfig(w http.ResponseWriter, r *http.Request, configKey string) { +// getVehicleConfig returns stored per-vehicle config data with fetched_at +// metadata. configKey may contain a VIN and must never be logged. +func (h *Handler) getVehicleConfig( + w http.ResponseWriter, + r *http.Request, + configKey, operation string, + vehicleID int64, +) { cfg, err := h.configRepo.GetByType(r.Context(), configKey) if err != nil { - log.Error().Err(err).Str("config_key", configKey).Msg("failed to fetch vehicle config") + trace.SpanFromContext(r.Context()).RecordError(errors.New("vehicle management cache read failed")) + event := log.Error(). + Str("trace_id", traceID(r.Context())). + Str("operation", operation) + if vehicleID > 0 { + event = event.Int64("vehicle_id", vehicleID) + } + event.Msg("failed to fetch cached Tesla vehicle management data") httpx.WriteError(w, http.StatusInternalServerError, "failed to fetch vehicle info") return } @@ -305,47 +549,48 @@ func (h *Handler) getVehicleConfig(w http.ResponseWriter, r *http.Request, confi // wrapped as {"enabled": } before persisting. func (h *Handler) refreshVehicleConfig( w http.ResponseWriter, r *http.Request, - configKey, configType, vin string, - fetch func() ([]byte, int, error), + configKey, configType string, + vehicleID int64, + fetch func(context.Context) ([]byte, int, error), + requirement tokenRequirement, isPaidEndpoint bool, ) { - if !h.teslaClient.HasValidToken() { - httpx.WriteError(w, http.StatusUnauthorized, "not authenticated with Tesla") + if requirement == userTokenRequired && !h.teslaClient.HasValidToken() { + httpx.WriteTeslaTokenExpired(w) return } - log.Info().Str("config_type", configType).Str("vin", vin).Msg("refreshing vehicle info from Tesla") - - body, status, err := fetch() - if err != nil { - log.Error().Err(err).Str("config_type", configType).Str("vin", vin).Msg("tesla vehicle info API error") - httpx.WriteError(w, http.StatusBadGateway, "failed to fetch from Tesla") - return + logEvent := log.Info().Str("operation", configType) + if vehicleID > 0 { + logEvent = logEvent.Int64("vehicle_id", vehicleID) } - if status < 200 || status >= 300 { - log.Error().Int("status", status).Str("config_type", configType).Str("vin", vin).Msg("tesla vehicle info non-2xx") - httpx.WriteError(w, http.StatusBadGateway, fmt.Sprintf("Tesla API returned status %d", status)) + logEvent.Msg("refreshing Tesla vehicle management data") + + ctx, cancel := context.WithTimeout(r.Context(), teslaInfoRequestTimeout) + defer cancel() + body, status, err := fetch(ctx) + if err != nil || status < http.StatusOK || status >= http.StatusMultipleChoices { + h.writeTeslaFailure(w, r, configType, vehicleID, status, err, requirement) return } - // Unwrap Tesla envelope: {"response": ...} - var envelope struct { - Response json.RawMessage `json:"response"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - log.Error().Err(err).Str("config_type", configType).Msg("failed to parse tesla response") + response, err := unwrapTeslaResponse(body) + if err != nil { + trace.SpanFromContext(r.Context()).RecordError(errors.New("invalid Tesla response")) + log.Error(). + Str("trace_id", traceID(r.Context())). + Str("operation", configType). + Int("status", status). + Msg("failed to parse Tesla vehicle management response") httpx.WriteError(w, http.StatusInternalServerError, "failed to parse Tesla response") return } - data := string(envelope.Response) + data := string(response) - // mobile_enabled returns a bare boolean (true/false) — wrap as - // {"enabled": }. The empty/null check below runs AFTER this guard, - // so we must skip wrapping when the response is missing/null; otherwise a - // blank Tesla response would produce syntactically invalid JSON such as - // {"enabled":} that later fails to parse on read. - if configType == "mobile_enabled" && data != "" && data != "null" { + // mobile_enabled returns a bare boolean (true/false). Wrap only those + // literals; direct JSON objects from other response variants stay intact. + if configType == "mobile_enabled" && (data == "true" || data == "false") { data = fmt.Sprintf(`{"enabled":%s}`, data) } @@ -354,14 +599,166 @@ func (h *Handler) refreshVehicleConfig( } if err := h.configRepo.Upsert(r.Context(), configKey, data); err != nil { - log.Error().Err(err).Str("config_key", configKey).Msg("failed to save vehicle info") + trace.SpanFromContext(r.Context()).RecordError(errors.New("vehicle management cache write failed")) + event := log.Error(). + Str("trace_id", traceID(r.Context())). + Str("operation", configType) + if vehicleID > 0 { + event = event.Int64("vehicle_id", vehicleID) + } + event.Msg("failed to save Tesla vehicle management data") httpx.WriteError(w, http.StatusInternalServerError, "failed to save vehicle info") return } if isPaidEndpoint { - log.Info().Str("vin", vin).Str("config_type", configType).Msg("paid Tesla API call completed and persisted") + log.Info(). + Int64("vehicle_id", vehicleID). + Str("operation", configType). + Int("status", status). + Msg("paid Tesla API call completed and persisted") + } + + h.getVehicleConfig(w, r, configKey, configType, vehicleID) +} + +func (h *Handler) executePartnerOperation( + w http.ResponseWriter, + r *http.Request, + operation string, + vehicleID int64, + fetch func(context.Context) ([]byte, int, error), +) { + ctx, cancel := context.WithTimeout(r.Context(), teslaInfoRequestTimeout) + defer cancel() + + body, status, err := fetch(ctx) + if err != nil || status < http.StatusOK || status >= http.StatusMultipleChoices { + h.writeTeslaFailure(w, r, operation, vehicleID, status, err, partnerTokenRequired) + return + } + + response, err := unwrapTeslaResponse(body) + if err != nil { + trace.SpanFromContext(r.Context()).RecordError(errors.New("invalid private Tesla response")) + event := log.Error(). + Str("trace_id", traceID(r.Context())). + Str("operation", operation). + Int("status", status) + if vehicleID > 0 { + event = event.Int64("vehicle_id", vehicleID) + } + event.Msg("failed to parse private Tesla vehicle management response") + httpx.WriteError(w, http.StatusBadGateway, "Tesla returned an invalid response") + return + } + + event := log.Info(). + Str("operation", operation). + Int("status", status) + if vehicleID > 0 { + event = event.Int64("vehicle_id", vehicleID) } + event.Msg("Tesla vehicle management operation completed") + httpx.WriteJSON(w, http.StatusOK, operationResultEnvelope{Data: response}) +} - h.getVehicleConfig(w, r, configKey) +func (h *Handler) writeTeslaFailure( + w http.ResponseWriter, + r *http.Request, + operation string, + vehicleID int64, + status int, + err error, + requirement tokenRequirement, +) { + trace.SpanFromContext(r.Context()).RecordError(errors.New("Tesla vehicle management operation failed")) + event := log.Error(). + Str("trace_id", traceID(r.Context())). + Str("operation", operation). + Int("status", status) + if vehicleID > 0 { + event = event.Int64("vehicle_id", vehicleID) + } + event.Msg("Tesla vehicle management operation failed") + + if errors.Is(err, tesla.ErrPartnerCredentialsMissing) { + httpx.WriteError(w, http.StatusPreconditionFailed, "Tesla partner credentials are not configured") + return + } + + switch status { + case http.StatusUnauthorized: + if requirement == userTokenRequired { + httpx.WriteTeslaTokenExpired(w) + return + } + httpx.WriteError(w, status, "Tesla partner authentication failed") + case http.StatusPaymentRequired: + httpx.WriteError(w, status, "Tesla requires payment or billing setup for this capability") + case http.StatusForbidden: + httpx.WriteError(w, status, "Tesla account lacks the required Fleet API scope or enterprise access") + case http.StatusPreconditionFailed: + httpx.WriteError(w, status, "Tesla account or vehicle does not meet this capability's prerequisites") + case http.StatusTooManyRequests: + httpx.WriteError(w, status, "Tesla rate limit reached; try again later") + default: + if status >= http.StatusBadRequest && status < http.StatusInternalServerError { + httpx.WriteError(w, status, "Tesla rejected the vehicle management request") + return + } + httpx.WriteError(w, http.StatusBadGateway, "failed to complete Tesla vehicle management request") + } +} + +func unwrapTeslaResponse(body []byte) (json.RawMessage, error) { + trimmed := bytes.TrimSpace(body) + if !json.Valid(trimmed) { + return nil, errors.New("decode Tesla response: invalid JSON") + } + + if len(trimmed) > 0 && trimmed[0] == '{' { + var object map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &object); err != nil { + return nil, fmt.Errorf("decode Tesla response object: %w", err) + } + if response, ok := object["response"]; ok { + return response, nil + } + } + + return json.RawMessage(append([]byte(nil), trimmed...)), nil +} + +func decodeLimitedJSON( + w http.ResponseWriter, + r *http.Request, + dst interface{}, +) *requestValidationError { + r.Body = http.MaxBytesReader(w, r.Body, maxOpaqueRequestBodyBytes) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(dst); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return &requestValidationError{ + status: http.StatusRequestEntityTooLarge, + message: "request body is too large", + } + } + if errors.Is(err, io.EOF) { + return &requestValidationError{status: http.StatusBadRequest, message: "request body is required"} + } + return &requestValidationError{status: http.StatusBadRequest, message: "invalid request body"} + } + + var trailing json.RawMessage + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return &requestValidationError{ + status: http.StatusBadRequest, + message: "request body must contain a single JSON object", + } + } + return nil } diff --git a/internal/api/vehicleinfo/handler_test.go b/internal/api/vehicleinfo/handler_test.go index a5b5cb8760..729524797d 100644 --- a/internal/api/vehicleinfo/handler_test.go +++ b/internal/api/vehicleinfo/handler_test.go @@ -58,8 +58,9 @@ var ( ) type teslaCall struct { - method string - vin string + method string + vin string + payload tesla.JSONRequestObject } // fakeTeslaClient returns the same canned (body,status,err) for every fetch @@ -99,8 +100,29 @@ func (f *fakeTeslaClient) GetUpgradeEligibility(_ context.Context, vin string) ( return f.record("upgrades", vin) } -func (f *fakeTeslaClient) GetWarrantyDetails(_ context.Context) ([]byte, int, error) { - return f.record("warranty", "") +func (f *fakeTeslaClient) GetWarrantyDetails(_ context.Context, vin string) ([]byte, int, error) { + return f.record("warranty", vin) +} + +func (f *fakeTeslaClient) GetVehiclePricing( + _ context.Context, + payload tesla.JSONRequestObject, +) ([]byte, int, error) { + f.calls = append(f.calls, teslaCall{method: "vehicle_pricing", payload: payload}) + return f.body, f.status, f.err +} + +func (f *fakeTeslaClient) GetEnterpriseRoles(_ context.Context, vin string) ([]byte, int, error) { + return f.record("enterprise_roles", vin) +} + +func (f *fakeTeslaClient) SetEnterprisePayer( + _ context.Context, + vin string, + payload tesla.JSONRequestObject, +) ([]byte, int, error) { + f.calls = append(f.calls, teslaCall{method: "enterprise_payer", vin: vin, payload: payload}) + return f.body, f.status, f.err } type upsertCall struct { @@ -176,6 +198,17 @@ func vehReq(method, vehicleID string) *http.Request { return r } +func vehJSONReq(method, vehicleID, body string) *http.Request { + r := httptest.NewRequest(method, "/x", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + if vehicleID != "" { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("vehicleID", vehicleID) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + } + return r +} + func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) vehicleInfoEnvelope { t.Helper() var env vehicleInfoEnvelope @@ -329,7 +362,8 @@ func TestGetHandlers_ConfigKeyRouting(t *testing.T) { {"specs", (*Handler).VehicleSpecs, "vehicle_specs:" + testVIN, true}, {"subscriptions", (*Handler).SubscriptionEligibility, "subscriptions:" + testVIN, true}, {"upgrades", (*Handler).UpgradeEligibility, "upgrades:" + testVIN, true}, - {"warranty", (*Handler).WarrantyDetails, "warranty", false}, + {"enterprise-roles", (*Handler).EnterpriseRoles, "enterprise_roles:" + testVIN, true}, + {"warranty", (*Handler).WarrantyDetails, "warranty:" + testVIN, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -448,13 +482,15 @@ func TestRefresh_SuccessAndPersistence(t *testing.T) { wantKey string wantData string needsVIN bool + confirmed bool }{ - {"mobile-enabled", (*Handler).RefreshMobileEnabled, `{"response":true}`, "mobile_enabled", "mobile_enabled:" + testVIN, `{"enabled":true}`, true}, - {"options", (*Handler).RefreshVehicleOptions, `{"response":{"codes":["A"]}}`, "vehicle_options", "vehicle_options:" + testVIN, `{"codes":["A"]}`, true}, - {"specs", (*Handler).RefreshVehicleSpecs, `{"response":{"weight":1000}}`, "vehicle_specs", "vehicle_specs:" + testVIN, `{"weight":1000}`, true}, - {"subscriptions", (*Handler).RefreshSubscriptionEligibility, `{"response":{"eligible":true}}`, "subscriptions", "subscriptions:" + testVIN, `{"eligible":true}`, true}, - {"upgrades", (*Handler).RefreshUpgradeEligibility, `{"response":{"tier":"x"}}`, "upgrades", "upgrades:" + testVIN, `{"tier":"x"}`, true}, - {"warranty", (*Handler).RefreshWarrantyDetails, `{"response":{"active":true}}`, "warranty", "warranty", `{"active":true}`, false}, + {"mobile-enabled", (*Handler).RefreshMobileEnabled, `{"response":true}`, "mobile_enabled", "mobile_enabled:" + testVIN, `{"enabled":true}`, true, false}, + {"options", (*Handler).RefreshVehicleOptions, `{"codes":[{"code":"$APF2","displayName":"Full Self-Driving (Supervised)","isActive":true}]}`, "vehicle_options", "vehicle_options:" + testVIN, `{"codes":[{"code":"$APF2","displayName":"Full Self-Driving (Supervised)","isActive":true}]}`, true, false}, + {"specs", (*Handler).RefreshVehicleSpecs, `{"response":{"weight":1000}}`, "vehicle_specs", "vehicle_specs:" + testVIN, `{"weight":1000}`, true, true}, + {"subscriptions", (*Handler).RefreshSubscriptionEligibility, `{"vin":"TESTVIN","country":"US","eligible":[{"product":"EXTENDED_WARRANTY"}]}`, "subscriptions", "subscriptions:" + testVIN, `{"vin":"TESTVIN","country":"US","eligible":[{"product":"EXTENDED_WARRANTY"}]}`, true, false}, + {"upgrades", (*Handler).RefreshUpgradeEligibility, `{}`, "upgrades", "upgrades:" + testVIN, `{}`, true, false}, + {"enterprise-roles", (*Handler).RefreshEnterpriseRoles, `{"response":{"roles":["fleet_manager"]}}`, "enterprise_roles", "enterprise_roles:" + testVIN, `{"roles":["fleet_manager"]}`, true, false}, + {"warranty", (*Handler).RefreshWarrantyDetails, `{"active":true}`, "warranty", "warranty:" + testVIN, `{"active":true}`, true, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -467,7 +503,11 @@ func TestRefresh_SuccessAndPersistence(t *testing.T) { if tt.needsVIN { vehicleID = "7" } - tt.call(h, rec, vehReq(http.MethodPost, vehicleID)) + req := vehReq(http.MethodPost, vehicleID) + if tt.confirmed { + req = vehJSONReq(http.MethodPost, vehicleID, `{"confirmed":true}`) + } + tt.call(h, rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) @@ -528,10 +568,10 @@ func TestRefresh_ErrorPaths(t *testing.T) { wantStatus: http.StatusBadGateway, }, { - name: "tesla non-2xx → 502", + name: "tesla authorization failure remains 403", tc: &fakeTeslaClient{validToken: true, status: http.StatusForbidden, body: []byte(`{"error":"nope"}`)}, cfg: newFakeConfigStore(), - wantStatus: http.StatusBadGateway, + wantStatus: http.StatusForbidden, }, { name: "unparseable tesla json → 500", @@ -684,7 +724,7 @@ func TestRefreshVehicleSpecs_FreshnessGuard(t *testing.T) { h := newHandler(tc, cfg, &fakeVehicleFinder{vehicle: okVehicle()}) rec := httptest.NewRecorder() - h.RefreshVehicleSpecs(rec, vehReq(http.MethodPost, "7")) + h.RefreshVehicleSpecs(rec, vehJSONReq(http.MethodPost, "7", `{"confirmed":true}`)) if rec.Code != tt.wantStatus { t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String()) @@ -696,6 +736,25 @@ func TestRefreshVehicleSpecs_FreshnessGuard(t *testing.T) { } } +func TestRefreshVehicleSpecs_RequiresExplicitConfirmation(t *testing.T) { + tc := &fakeTeslaClient{validToken: true, status: http.StatusOK, body: []byte(`{"response":{}}`)} + cfg := newFakeConfigStore() + h := newHandler(tc, cfg, &fakeVehicleFinder{vehicle: okVehicle()}) + + rec := httptest.NewRecorder() + h.RefreshVehicleSpecs(rec, vehJSONReq(http.MethodPost, "7", `{"confirmed":false}`)) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if len(tc.calls) != 0 { + t.Fatalf("Tesla calls = %d, want 0", len(tc.calls)) + } + if len(cfg.upserts) != 0 { + t.Fatalf("upserts = %d, want 0", len(cfg.upserts)) + } +} + // A specs refresh that is guarded away must persist nothing. func TestRefreshVehicleSpecs_GuardDoesNotPersist(t *testing.T) { key := "vehicle_specs:" + testVIN @@ -705,7 +764,7 @@ func TestRefreshVehicleSpecs_GuardDoesNotPersist(t *testing.T) { h := newHandler(tc, cfg, &fakeVehicleFinder{vehicle: okVehicle()}) rec := httptest.NewRecorder() - h.RefreshVehicleSpecs(rec, vehReq(http.MethodPost, "7")) + h.RefreshVehicleSpecs(rec, vehJSONReq(http.MethodPost, "7", `{"confirmed":true}`)) if rec.Code != http.StatusTooManyRequests { t.Fatalf("status = %d, want 429", rec.Code) @@ -715,25 +774,26 @@ func TestRefreshVehicleSpecs_GuardDoesNotPersist(t *testing.T) { } } -// The warranty endpoints are account-level and must not require a vehicle id. -func TestWarranty_DoesNotRequireVehicleID(t *testing.T) { - tc := &fakeTeslaClient{validToken: true, status: http.StatusOK, body: []byte(`{"response":{"active":true}}`)} +func TestWarranty_RequiresVehicleAndUsesVIN(t *testing.T) { + tc := &fakeTeslaClient{validToken: true, status: http.StatusOK, body: []byte(`{"active":true}`)} cfg := newFakeConfigStore() - // A finder that would fail if consulted proves warranty never resolves a VIN. - veh := &fakeVehicleFinder{err: errors.New("should not be called")} + veh := &fakeVehicleFinder{vehicle: okVehicle()} h := newHandler(tc, cfg, veh) rec := httptest.NewRecorder() - h.RefreshWarrantyDetails(rec, vehReq(http.MethodPost, "")) + h.RefreshWarrantyDetails(rec, vehReq(http.MethodPost, "7")) if rec.Code != http.StatusOK { t.Fatalf("refresh status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } - if veh.gotID != 0 { - t.Fatalf("vehicle finder was consulted for account-level warranty") + if veh.gotID != 7 { + t.Fatalf("vehicle finder got id = %d, want 7", veh.gotID) + } + if len(tc.calls) != 1 || tc.calls[0].vin != testVIN { + t.Fatalf("Tesla calls = %+v, want warranty call for selected VIN", tc.calls) } rec = httptest.NewRecorder() - h.WarrantyDetails(rec, vehReq(http.MethodGet, "")) + h.WarrantyDetails(rec, vehReq(http.MethodGet, "7")) if rec.Code != http.StatusOK { t.Fatalf("get status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } @@ -742,3 +802,319 @@ func TestWarranty_DoesNotRequireVehicleID(t *testing.T) { t.Fatalf("warranty data = %s, want {\"active\":true}", env.Data) } } + +func TestOpaqueManagementRequestsRejectInvalidBodiesWithoutTeslaCall(t *testing.T) { + oversized := `{"payload":{"blob":"` + + strings.Repeat("x", int(maxOpaqueRequestBodyBytes)) + + `"}}` + tests := []struct { + name string + call func(*Handler, http.ResponseWriter, *http.Request) + vehicleID string + body string + wantStatus int + }{ + { + name: "pricing body required", + call: (*Handler).VehiclePricing, + body: "", + wantStatus: http.StatusBadRequest, + }, + { + name: "pricing payload rejects array", + call: (*Handler).VehiclePricing, + body: `{"payload":[]}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "pricing payload rejects scalar", + call: (*Handler).VehiclePricing, + body: `{"payload":"unknown schema"}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "pricing payload rejects null", + call: (*Handler).VehiclePricing, + body: `{"payload":null}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "pricing payload rejects empty object", + call: (*Handler).VehiclePricing, + body: `{"payload":{}}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "pricing rejects trailing JSON", + call: (*Handler).VehiclePricing, + body: `{"payload":{"opaque":true}} {}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "pricing rejects unknown wrapper fields", + call: (*Handler).VehiclePricing, + body: `{"payload":{"opaque":true},"method":"DELETE"}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "pricing enforces small body limit", + call: (*Handler).VehiclePricing, + body: oversized, + wantStatus: http.StatusRequestEntityTooLarge, + }, + { + name: "payer requires explicit confirmation", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: `{"payload":{"opaque":true}}`, + wantStatus: http.StatusPreconditionFailed, + }, + { + name: "payer rejects false confirmation", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: `{"payload":{"opaque":true},"confirmed":false}`, + wantStatus: http.StatusPreconditionFailed, + }, + { + name: "payer rejects empty object", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: `{"payload":{},"confirmed":true}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "payer rejects array", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: `{"payload":[1],"confirmed":true}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "payer rejects scalar", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: `{"payload":"unknown schema","confirmed":true}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "payer rejects null", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: `{"payload":null,"confirmed":true}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "payer rejects trailing JSON", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: `{"payload":{"opaque":true},"confirmed":true} null`, + wantStatus: http.StatusBadRequest, + }, + { + name: "payer enforces small body limit", + call: (*Handler).EnterprisePayer, + vehicleID: "7", + body: strings.Replace(oversized, "}}", `},"confirmed":true}`, 1), + wantStatus: http.StatusRequestEntityTooLarge, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc := &fakeTeslaClient{ + status: http.StatusOK, + body: []byte(`{"response":{"ok":true}}`), + } + cfg := newFakeConfigStore() + h := newHandler(tc, cfg, &fakeVehicleFinder{vehicle: okVehicle()}) + rec := httptest.NewRecorder() + + tt.call(h, rec, vehJSONReq(http.MethodPost, tt.vehicleID, tt.body)) + + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String()) + } + if len(tc.calls) != 0 { + t.Fatalf("invalid request made %d Tesla call(s), want 0", len(tc.calls)) + } + if len(cfg.upserts) != 0 { + t.Fatalf("invalid request persisted %d config row(s), want 0", len(cfg.upserts)) + } + }) + } +} + +func TestVehiclePricingForwardsOnlyOpaqueObject(t *testing.T) { + tc := &fakeTeslaClient{ + status: http.StatusOK, + body: []byte(`{"response":{"currency":"USD","quote":17}}`), + } + cfg := newFakeConfigStore() + h := newHandler(tc, cfg, &fakeVehicleFinder{vehicle: okVehicle()}) + rec := httptest.NewRecorder() + + h.VehiclePricing( + rec, + vehJSONReq( + http.MethodPost, + "", + `{"payload":{"opaque":{"nested":[1,true]}}}`, + ), + ) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if len(tc.calls) != 1 || tc.calls[0].method != "vehicle_pricing" { + t.Fatalf("calls = %+v, want one vehicle_pricing call", tc.calls) + } + if got := string(tc.calls[0].payload["opaque"]); got != `{"nested":[1,true]}` { + t.Fatalf("opaque payload = %s", got) + } + if len(cfg.upserts) != 0 { + t.Fatalf("pricing persisted %d row(s), want 0", len(cfg.upserts)) + } + var result operationResultEnvelope + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("decode response: %v", err) + } + if string(result.Data) != `{"currency":"USD","quote":17}` { + t.Fatalf("response data = %s", result.Data) + } +} + +func TestEnterpriseRolesCachedReadAndExplicitRefresh(t *testing.T) { + key := "enterprise_roles:" + testVIN + cfg := newFakeConfigStore() + cfg.byType[key] = &teslamodel.TeslaUserConfig{ + ConfigType: key, + Data: `{"roles":["cached_role"]}`, + FetchedAt: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC), + } + tc := &fakeTeslaClient{ + validToken: false, + status: http.StatusOK, + body: []byte(`{"response":{"roles":["refreshed_role"]}}`), + } + h := newHandler(tc, cfg, &fakeVehicleFinder{vehicle: okVehicle()}) + + rec := httptest.NewRecorder() + h.EnterpriseRoles(rec, vehReq(http.MethodGet, "7")) + if rec.Code != http.StatusOK { + t.Fatalf("cached GET status = %d, want 200", rec.Code) + } + if len(tc.calls) != 0 { + t.Fatalf("cached GET made %d Tesla call(s), want 0", len(tc.calls)) + } + if got := string(decodeEnvelope(t, rec).Data); got != `{"roles":["cached_role"]}` { + t.Fatalf("cached data = %s", got) + } + + rec = httptest.NewRecorder() + h.RefreshEnterpriseRoles(rec, vehReq(http.MethodPost, "7")) + if rec.Code != http.StatusOK { + t.Fatalf("refresh status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if len(tc.calls) != 1 || tc.calls[0].method != "enterprise_roles" { + t.Fatalf("calls = %+v, want one enterprise_roles call", tc.calls) + } + if tc.calls[0].vin != testVIN { + t.Fatalf("roles VIN = %q, want test VIN", tc.calls[0].vin) + } + if len(cfg.upserts) != 1 || cfg.upserts[0].configType != key { + t.Fatalf("upserts = %+v, want enterprise roles cache row", cfg.upserts) + } +} + +func TestPartnerOnlyRefreshDoesNotRequireUserToken(t *testing.T) { + tc := &fakeTeslaClient{ + validToken: false, + status: http.StatusOK, + body: []byte(`{"response":{"mass_kg":1800}}`), + } + h := newHandler(tc, newFakeConfigStore(), &fakeVehicleFinder{vehicle: okVehicle()}) + rec := httptest.NewRecorder() + + h.RefreshVehicleSpecs(rec, vehJSONReq(http.MethodPost, "7", `{"confirmed":true}`)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if len(tc.calls) != 1 || tc.calls[0].method != "vehicle_specs" { + t.Fatalf("calls = %+v, want partner-scoped specs call", tc.calls) + } +} + +func TestEnterprisePayerRequiresConfirmationAndNeverPersistsPayload(t *testing.T) { + tc := &fakeTeslaClient{ + status: http.StatusOK, + body: []byte(`{"response":{"updated":true}}`), + } + cfg := newFakeConfigStore() + h := newHandler(tc, cfg, &fakeVehicleFinder{vehicle: okVehicle()}) + rec := httptest.NewRecorder() + + h.EnterprisePayer( + rec, + vehJSONReq( + http.MethodPost, + "7", + `{"payload":{"opaque":{"nested":[1,true]}},"confirmed":true}`, + ), + ) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if len(tc.calls) != 1 || tc.calls[0].method != "enterprise_payer" { + t.Fatalf("calls = %+v, want one enterprise_payer call", tc.calls) + } + if tc.calls[0].vin != testVIN { + t.Fatalf("payer VIN = %q, want test VIN", tc.calls[0].vin) + } + if got := string(tc.calls[0].payload["opaque"]); got != `{"nested":[1,true]}` { + t.Fatalf("payer payload = %s", got) + } + if len(cfg.upserts) != 0 { + t.Fatalf("payer persisted %d row(s), want 0", len(cfg.upserts)) + } +} + +func TestPartnerCapabilityFailuresPreserveExpectedStatus(t *testing.T) { + tests := []struct { + name string + status int + err error + wantStatus int + }{ + {"partner credentials missing", 0, tesla.ErrPartnerCredentialsMissing, http.StatusPreconditionFailed}, + {"partner authentication", http.StatusUnauthorized, errors.New("unauthorized"), http.StatusUnauthorized}, + {"payment required", http.StatusPaymentRequired, nil, http.StatusPaymentRequired}, + {"scope forbidden", http.StatusForbidden, nil, http.StatusForbidden}, + {"precondition", http.StatusPreconditionFailed, nil, http.StatusPreconditionFailed}, + {"rate limited", http.StatusTooManyRequests, nil, http.StatusTooManyRequests}, + {"upstream server error", http.StatusInternalServerError, errors.New("upstream"), http.StatusBadGateway}, + {"transport error", 0, errors.New("timeout"), http.StatusBadGateway}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc := &fakeTeslaClient{status: tt.status, err: tt.err} + h := newHandler(tc, newFakeConfigStore(), &fakeVehicleFinder{vehicle: okVehicle()}) + rec := httptest.NewRecorder() + + h.VehiclePricing( + rec, + vehJSONReq(http.MethodPost, "", `{"payload":{"opaque":true}}`), + ) + + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"data"`) { + t.Fatalf("failure returned success-shaped data: %s", rec.Body.String()) + } + }) + } +} diff --git a/internal/app/new.go b/internal/app/new.go index 72c1a5f07f..893847e165 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -123,6 +123,7 @@ func New(ctx context.Context, cfg *config.Config, build BuildInfo) (*App, error) if err := a.initTelemetryHandler(ctx); err != nil { return a, err } + a.initChargingPlaceHistoryBackfill(ctx) a.initWorker(ctx) a.initNotificationWorker(ctx) @@ -786,7 +787,9 @@ func (a *App) initTelemetryHandler(ctx context.Context) error { a.TelemetryHandler.StartCleanup(ctx) - go a.TelemetryHandler.SessionTracker().BackfillAddresses(ctx) + if sessionTracker != nil { + go sessionTracker.BackfillAddresses(ctx) + } if a.MQTT != nil && a.Cfg.FleetTelemetry.TopicBase != "" { if err := a.initPipelineSubscriber(ctx, vehicleRepo); err != nil { @@ -796,6 +799,19 @@ func (a *App) initTelemetryHandler(ctx context.Context) error { return nil } +// initChargingPlaceHistoryBackfill runs independently of Fleet Telemetry. +// Polling-only installations still have historical charging sessions that +// need place attribution and current-rate estimates. +func (a *App) initChargingPlaceHistoryBackfill(ctx context.Context) { + var sessionTracker *apitelem.TelemetrySessionTracker + if a.TelemetryHandler != nil { + sessionTracker = a.TelemetryHandler.SessionTracker() + } else { + sessionTracker = apitelem.NewTelemetrySessionTracker(a.DB, a.EventBus, nil, nil) + } + sessionTracker.StartChargingPlaceHistoryBackfill(ctx) +} + // initPipelineSubscriber wires the telemetry ingest stack: // 12 writers → router.New → unit-history cache+repo → SideEffectsObserver // + SoftwareUpdateObserver → normalize.Pipeline → MQTT PipelineSubscriber. diff --git a/internal/app/wiring_test.go b/internal/app/wiring_test.go index 903b3342e4..471ce15b16 100644 --- a/internal/app/wiring_test.go +++ b/internal/app/wiring_test.go @@ -143,3 +143,28 @@ func TestCutover_ProductionMQTTHelperWired(t *testing.T) { t.Fatal("phase-42a/0050 cutover incomplete: mqtt.NewProductionPipelineMQTT( missing from internal/app; the PipelineSubscriber requires the auto-ack-disabled paho client + DLQ produced by this helper (added in phase-42a/0040)") } } + +func TestChargingPlaceHistoryBackfillRunsWithoutFleetTelemetry(t *testing.T) { + src := readAppSources(t) + for _, required := range []string{ + "a.initChargingPlaceHistoryBackfill(ctx)", + "sessionTracker = apitelem.NewTelemetrySessionTracker(a.DB, a.EventBus, nil, nil)", + "sessionTracker.StartChargingPlaceHistoryBackfill(ctx)", + } { + if !strings.Contains(src, required) { + t.Errorf("charging-place history backfill wiring missing %q", required) + } + } + + initTelemetryAt := strings.Index(src, "if err := a.initTelemetryHandler(ctx); err != nil") + backfillAt := strings.Index(src, "a.initChargingPlaceHistoryBackfill(ctx)") + initWorkerAt := strings.Index(src, "a.initWorker(ctx)") + if initTelemetryAt < 0 || backfillAt < initTelemetryAt || initWorkerAt < backfillAt { + t.Fatalf( + "charging-place backfill must start after telemetry initialization and before workers: telemetry=%d backfill=%d worker=%d", + initTelemetryAt, + backfillAt, + initWorkerAt, + ) + } +} diff --git a/internal/database/charging/repo.go b/internal/database/charging/repo.go index 1f871d59ab..429adf17e4 100644 --- a/internal/database/charging/repo.go +++ b/internal/database/charging/repo.go @@ -9,6 +9,7 @@ import ( chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging" "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" ) // ChargingRepo provides charging session data access against the SI canonical @@ -26,7 +27,8 @@ const chargingColumns = `id, vehicle_id, started_at, ended_at, start_soc_pct, end_soc_pct, delta_soc_pct, start_odometer_m, end_odometer_m, start_lat, start_lng, start_place, total_energy_added_wh, peak_power_w, avg_power_w, -cost_decimal, cost_currency, cable_type, charger_type` +cost_decimal, cost_currency, cable_type, charger_type, +geofence_id, rate_id, cost_source` func scanChargingSession(row interface{ Scan(dest ...any) error }) (*chargingmodel.ChargingSession, error) { c := &chargingmodel.ChargingSession{} @@ -36,6 +38,7 @@ func scanChargingSession(row interface{ Scan(dest ...any) error }) (*chargingmod &c.StartOdometerM, &c.EndOdometerM, &c.StartLat, &c.StartLng, &c.StartPlace, &c.TotalEnergyAddedWh, &c.PeakPowerW, &c.AvgPowerW, &c.CostDecimal, &c.CostCurrency, &c.CableType, &c.ChargerType, + &c.GeofenceID, &c.RateID, &c.CostSource, ) if err != nil { return nil, err @@ -61,7 +64,8 @@ UPDATE charging_sessions SET ended_at=$2, total_energy_added_wh=$3, end_soc_pct=$4, delta_soc_pct = CASE WHEN start_soc_pct IS NOT NULL AND $4::double precision IS NOT NULL THEN $4::double precision - start_soc_pct ELSE delta_soc_pct END, peak_power_w=$5, avg_power_w=$6, -cost_decimal=$7, cost_currency=$8 +cost_decimal=COALESCE($7, cost_decimal), +cost_currency=COALESCE($8, cost_currency) WHERE id=$1` _, err := r.db.Pool.Exec(ctx, query, id, endTs, totalEnergyAddedWh, endSocPct, peakPowerW, avgPowerW, costDecimal, costCurrency) @@ -98,7 +102,14 @@ func (r *ChargingRepo) GetByVehicle(ctx context.Context, vehicleID int64, limit, } sessions = append(sessions, c) } - return sessions, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if err := r.resolveCurrentGeofenceNames(ctx, sessions); err != nil { + log.Warn().Err(err).Int64("vehicle_id", vehicleID). + Msg("charging sessions: failed to resolve current geofence names") + } + return sessions, nil } func (r *ChargingRepo) GetByID(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error) { @@ -110,6 +121,10 @@ func (r *ChargingRepo) GetByID(ctx context.Context, id int64) (*chargingmodel.Ch if err != nil { return nil, err } + if err := r.resolveCurrentGeofenceNames(ctx, []*chargingmodel.ChargingSession{c}); err != nil { + log.Warn().Err(err).Int64("session_id", id). + Msg("charging session: failed to resolve current geofence name") + } return c, nil } @@ -131,7 +146,62 @@ ORDER BY started_at DESC` } sessions = append(sessions, c) } - return sessions, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if err := r.resolveCurrentGeofenceNames(ctx, sessions); err != nil { + log.Warn().Err(err).Msg("stale charging sessions: failed to resolve current geofence names") + } + return sessions, nil +} + +// resolveCurrentGeofenceNames overlays the latest user-defined geofence name +// onto a session's stored start_place fallback. Renaming an auto-discovered +// place therefore improves historical charging labels without rewriting +// session rows; if the geofence cannot be resolved, the original text stays. +func (r *ChargingRepo) resolveCurrentGeofenceNames(ctx context.Context, sessions []*chargingmodel.ChargingSession) error { + ids := make(map[int64]struct{}) + for _, session := range sessions { + if session != nil && session.GeofenceID != nil { + ids[*session.GeofenceID] = struct{}{} + } + } + if len(ids) == 0 { + return nil + } + + idList := make([]int64, 0, len(ids)) + for id := range ids { + idList = append(idList, id) + } + rows, err := r.db.Pool.Query(ctx, `SELECT id, name FROM geofences WHERE id = ANY($1)`, idList) + if err != nil { + return err + } + defer rows.Close() + + names := make(map[int64]string, len(ids)) + for rows.Next() { + var id int64 + var name string + if err := rows.Scan(&id, &name); err != nil { + return err + } + names[id] = name + } + if err := rows.Err(); err != nil { + return err + } + + for _, session := range sessions { + if session == nil || session.GeofenceID == nil { + continue + } + if name, ok := names[*session.GeofenceID]; ok { + session.StartPlace = &name + } + } + return nil } var ChargingPartialAllowed = map[string]string{ @@ -151,6 +221,9 @@ var ChargingPartialAllowed = map[string]string{ "start_lng": "start_lng", "start_odometer_m": "start_odometer_m", "end_odometer_m": "end_odometer_m", + "geofence_id": "geofence_id", + "rate_id": "rate_id", + "cost_source": "cost_source", } func filterChargingPartialFields(in map[string]interface{}) map[string]interface{} { @@ -225,7 +298,8 @@ UPDATE charging_sessions SET ended_at=$2, total_energy_added_wh=$3, end_soc_pct=$4, delta_soc_pct = CASE WHEN start_soc_pct IS NOT NULL AND $4::double precision IS NOT NULL THEN $4::double precision - start_soc_pct ELSE delta_soc_pct END, peak_power_w=$5, avg_power_w=$6, -cost_decimal=$7, cost_currency=$8 +cost_decimal=COALESCE($7, cost_decimal), +cost_currency=COALESCE($8, cost_currency) WHERE id=$1` _, err := tx.Exec(ctx, query, id, endTs, totalEnergyAddedWh, endSocPct, peakPowerW, avgPowerW, costDecimal, costCurrency) @@ -256,3 +330,42 @@ UPDATE charging_telemetry } return tag.RowsAffected(), nil } + +// ApplyGeofenceTariff prices a single completed charging session using one +// specific geofence rate version, computing cost_decimal via PostgreSQL +// NUMERIC arithmetic (never Go float64 multiplication) per the SI / +// no-binary-float-money design rule. total_energy_added_wh is already SI +// watt-hours, so no unit conversion is needed — only a currency-safe decimal +// multiply. +// +// It is idempotent and safe to call repeatedly (e.g. from the async +// post-completion telemetry leg, which may retry): the WHERE clause can +// replace a default estimate and reapply the SAME pinned geofence rate, while +// NULL/unknown provenance is only eligible when cost_decimal is also NULL. +// A different geofence rate can only replace history through the explicit +// preview/apply flow. It never touches manual, Tesla-actual, or pre-feature +// costs with unknown provenance. Returns whether a row was actually updated +// (false means the session already has a protected cost, is missing energy +// data, or does not exist). +func (r *ChargingRepo) ApplyGeofenceTariff(ctx context.Context, sessionID, geofenceID, rateID int64, ratePerWh float64, currency string) (bool, error) { + const query = ` +UPDATE charging_sessions + SET cost_decimal = ROUND((COALESCE(total_energy_added_wh, 0)::numeric * $4::numeric), 6), + cost_currency = $5, + geofence_id = $2, + rate_id = $3, + cost_source = 'geofence_tariff' + WHERE id = $1 + AND total_energy_added_wh IS NOT NULL + AND ( + cost_source = 'default_estimate' + OR (cost_source = 'geofence_tariff' AND rate_id = $3) + OR (cost_source IS NULL AND cost_decimal IS NULL) + OR (cost_source = 'unknown' AND cost_decimal IS NULL) + )` + tag, err := r.db.Pool.Exec(ctx, query, sessionID, geofenceID, rateID, ratePerWh, currency) + if err != nil { + return false, fmt.Errorf("apply geofence tariff to session %d: %w", sessionID, err) + } + return tag.RowsAffected() > 0, nil +} diff --git a/internal/database/drive/repo.go b/internal/database/drive/repo.go index 7a4934da5e..1f92ba1f74 100644 --- a/internal/database/drive/repo.go +++ b/internal/database/drive/repo.go @@ -11,6 +11,7 @@ import ( "github.com/ev-dev-labs/teslasync/internal/tracing" "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" ) // SI canonical schema (migration 000185_drives_si). The drives table is @@ -47,7 +48,7 @@ const driveColumns = `id, vehicle_id, started_at, ended_at, duration_s, distance start_place, end_place, start_lat, start_lng, end_lat, end_lng, start_soc_pct, end_soc_pct, energy_used_wh, regen_energy_wh, avg_speed_mps, max_speed_mps, avg_power_w, - ambient_temp_c_avg` + ambient_temp_c_avg, start_geofence_id, end_geofence_id` // scanDrive scans the SI canonical column list into a drivemodel.Drive. No unit // conversion is performed — both struct and DB are SI canonical. @@ -64,7 +65,7 @@ func scanDrive(row interface{ Scan(dest ...any) error }) (*drivemodel.Drive, err &d.StartAddress, &d.EndAddress, &d.StartLat, &d.StartLon, &d.EndLat, &d.EndLon, &startSocPct, &endSocPct, &d.EnergyUsedWh, &d.RegenEnergyWh, &d.AvgSpeedMps, &d.MaxSpeedMps, &d.AvgPowerW, - &d.OutsideTempAvgC, + &d.OutsideTempAvgC, &d.StartGeofenceID, &d.EndGeofenceID, ) if err != nil { return nil, err @@ -191,7 +192,15 @@ func (r *DriveRepo) GetByVehicle(ctx context.Context, vehicleID int64, limit, of } drives = append(drives, d) } - return drives, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if err := r.resolveCurrentGeofenceNames(ctx, drives); err != nil { + // Non-fatal: the list is still correct using stored start_place/ + // end_place text, just not refreshed with a since-renamed place. + log.Warn().Err(err).Int64("vehicle_id", vehicleID).Msg("drives: failed to resolve current geofence names") + } + return drives, nil } func (r *DriveRepo) GetByID(ctx context.Context, id int64) (*drivemodel.Drive, error) { @@ -203,7 +212,88 @@ func (r *DriveRepo) GetByID(ctx context.Context, id int64) (*drivemodel.Drive, e return nil, nil } tracing.EndSpan(span, err) - return d, err + if err != nil { + return nil, err + } + if err := r.resolveCurrentGeofenceNames(ctx, []*drivemodel.Drive{d}); err != nil { + log.Warn().Err(err).Int64("drive_id", id).Msg("drives: failed to resolve current geofence names") + } + return d, nil +} + +// resolveCurrentGeofenceNames overlays each drive's StartAddress/EndAddress +// with the CURRENT name of its attached geofence (StartGeofenceID / +// EndGeofenceID), leaving the originally-stored start_place/end_place text +// untouched as the fallback when no geofence is attached or the batch +// lookup comes up empty for that id. This is what lets a place rename +// (e.g. "Unnamed Charging Place" → "Grandma's House") retroactively improve +// historical drive display without rewriting any stored row. +// +// Deliberately does NOT filter on archived_at: "archived places... remain +// resolvable for history" means an archived place's current (possibly +// renamed) name is still preferred over the frozen fallback text — only a +// truly nonexistent id (never possible today; ids are never hard-deleted) +// would fail to resolve and fall back silently. +// +// One batched `id = ANY($1)` query regardless of how many drives are +// passed in, so GetByVehicle's page of (up to) LIMIT rows costs one extra +// round trip, not one per row. +func (r *DriveRepo) resolveCurrentGeofenceNames(ctx context.Context, drives []*drivemodel.Drive) error { + ids := make(map[int64]struct{}) + for _, d := range drives { + if d == nil { + continue + } + if d.StartGeofenceID != nil { + ids[*d.StartGeofenceID] = struct{}{} + } + if d.EndGeofenceID != nil { + ids[*d.EndGeofenceID] = struct{}{} + } + } + if len(ids) == 0 { + return nil + } + idList := make([]int64, 0, len(ids)) + for id := range ids { + idList = append(idList, id) + } + + rows, err := r.db.Pool.Query(ctx, `SELECT id, name FROM geofences WHERE id = ANY($1)`, idList) + if err != nil { + return fmt.Errorf("drives resolve_geofence_names query: %w", err) + } + defer rows.Close() + + names := make(map[int64]string, len(idList)) + for rows.Next() { + var id int64 + var name string + if err := rows.Scan(&id, &name); err != nil { + return fmt.Errorf("drives resolve_geofence_names scan: %w", err) + } + names[id] = name + } + if err := rows.Err(); err != nil { + return fmt.Errorf("drives resolve_geofence_names iter: %w", err) + } + + for _, d := range drives { + if d == nil { + continue + } + if d.StartGeofenceID != nil { + if name, ok := names[*d.StartGeofenceID]; ok { + d.StartAddress = &name + } + } + if d.EndGeofenceID != nil { + if name, ok := names[*d.EndGeofenceID]; ok { + d.EndAddress = &name + } + } + } + return nil } // GetStale returns drives that have no end timestamp and started before the @@ -295,6 +385,11 @@ var DrivePartialAllowed = map[string]string{ // values in meters directly. "start_odometer_m": "start_odometer_m", "end_odometer_m": "end_odometer_m", + // Charging-place geofence attribution (migration + // 000228_geofence_charging_place_pricing). Match-only, no DB FK — see + // drivemodel.Drive.StartGeofenceID / EndGeofenceID doc comment. + "start_geofence_id": "start_geofence_id", + "end_geofence_id": "end_geofence_id", } // PartialUpdate updates only the provided fields on a drive. The fields map diff --git a/internal/database/drive/repo_geofence_names_test.go b/internal/database/drive/repo_geofence_names_test.go new file mode 100644 index 0000000000..c128ce4e0a --- /dev/null +++ b/internal/database/drive/repo_geofence_names_test.go @@ -0,0 +1,197 @@ +package drive + +import ( + "context" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// ============================================================================= +// repo_geofence_names_test.go — proves resolveCurrentGeofenceNames' central +// contract for the geofence-based charging-place pricing feature (migration +// 000228_geofence_charging_place_pricing), business requirement #5: "The +// same geofence identity should support drive start/end naming so renames +// can improve historical display, with existing place text retained as +// fallback." +// +// DriveRepo has no fake-pool test seam (concrete db *database.DB field, not +// an interface) — same architectural gap as ChargingRepo — so this reuses +// the file's own established live-database pattern +// (repairDSNOrSkip/openRepairTestDB, already defined in +// place_label_repair_test.go in this package) rather than inventing a new +// mocking approach. +// ============================================================================= + +// seedGeofenceNameFixture inserts one geofence directly (this package does +// not import internal/database/geofence to avoid a needless cross-package +// dependency for a single fixture row), returning its id. Removed via +// t.Cleanup. +func seedGeofenceNameFixture(t *testing.T, db *database.DB, name string) int64 { + t.Helper() + ctx := context.Background() + var id int64 + err := db.Pool.QueryRow(ctx, ` +INSERT INTO geofences (name, polygon_wkt, category, enabled, origin, needs_review) +VALUES ($1, 'POLYGON((0 0,0 0.001,0.001 0.001,0.001 0,0 0))', 'custom', true, 'manual', false) +RETURNING id`, name).Scan(&id) + if err != nil { + t.Fatalf("seed geofence: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM geofences WHERE id = $1`, id) + }) + return id +} + +func renameGeofence(t *testing.T, db *database.DB, geofenceID int64, newName string) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), `UPDATE geofences SET name = $1 WHERE id = $2`, newName, geofenceID); err != nil { + t.Fatalf("rename geofence %d: %v", geofenceID, err) + } +} + +// TestGetByID_GeofenceRename_RetroactivelyImprovesDisplay is the direct +// regression test for business requirement #5: renaming a geofence must +// change what GetByID returns for a drive attached to it — on the NEXT +// read, with no rewrite of the drive row itself — while the originally +// stored start_place/end_place text remains in the database as the +// permanent fallback (proven by the "no geofence attached" drive in the +// same test never changing). +func TestGetByID_GeofenceRename_RetroactivelyImprovesDisplay(t *testing.T) { + db := openRepairTestDB(t, repairDSNOrSkip(t)) + repo := NewDriveRepo(db) + vehicleID := seedRepairFixture(t, db) + + geofenceID := seedGeofenceNameFixture(t, db, "Unnamed Charging Place") + + var attachedDriveID int64 + err := db.Pool.QueryRow(context.Background(), ` +INSERT INTO drives (vehicle_id, started_at, start_place, start_geofence_id, place_label_version) +VALUES ($1, now(), 'stored fallback text from discovery time', $2, $3) +RETURNING id`, vehicleID, geofenceID, PlaceLabelVersion).Scan(&attachedDriveID) + if err != nil { + t.Fatalf("seed attached drive: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM drives WHERE id = $1`, attachedDriveID) + }) + + // A sibling drive with NO geofence attached — its stored start_place + // must never be overlaid by anything, proving the fallback path is + // untouched by the rename below. + var unattachedDriveID int64 + err = db.Pool.QueryRow(context.Background(), ` +INSERT INTO drives (vehicle_id, started_at, start_place, place_label_version) +VALUES ($1, now(), 'permanent fallback text', $2) +RETURNING id`, vehicleID, PlaceLabelVersion).Scan(&unattachedDriveID) + if err != nil { + t.Fatalf("seed unattached drive: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM drives WHERE id = $1`, unattachedDriveID) + }) + + // Before any rename: the attached drive's displayed name is the + // geofence's original name (an overlay, not the stored fallback text) — + // resolveCurrentGeofenceNames already prefers the live geofence name + // even on the very first read. + before, err := repo.GetByID(context.Background(), attachedDriveID) + if err != nil { + t.Fatalf("GetByID (before rename): %v", err) + } + if before.StartAddress == nil || *before.StartAddress != "Unnamed Charging Place" { + t.Fatalf("StartAddress before rename = %v, want the geofence's current name %q", before.StartAddress, "Unnamed Charging Place") + } + + // Rename the geofence — simulating a user editing "Unnamed Charging + // Place" to a friendly name once they review it in the Charging Places + // UI. No drive row is touched by this statement. + renameGeofence(t, db, geofenceID, "Grandma's House") + + after, err := repo.GetByID(context.Background(), attachedDriveID) + if err != nil { + t.Fatalf("GetByID (after rename): %v", err) + } + if after.StartAddress == nil || *after.StartAddress != "Grandma's House" { + t.Fatalf("StartAddress after rename = %v, want the NEW geofence name %q — rename must retroactively improve display", after.StartAddress, "Grandma's House") + } + + // The unattached sibling drive's stored text must be completely + // unaffected by the rename — it has no geofence to overlay from. + unattached, err := repo.GetByID(context.Background(), unattachedDriveID) + if err != nil { + t.Fatalf("GetByID (unattached): %v", err) + } + if unattached.StartAddress == nil || *unattached.StartAddress != "permanent fallback text" { + t.Fatalf("unattached drive StartAddress = %v, want unchanged stored fallback text", unattached.StartAddress) + } + if unattached.StartGeofenceID != nil { + t.Fatalf("unattached drive StartGeofenceID = %v, want nil", unattached.StartGeofenceID) + } +} + +// TestGetByVehicle_BatchesGeofenceNameResolution proves +// resolveCurrentGeofenceNames' batched (single query regardless of row +// count) contract still resolves correctly across a page of many drives — +// a regression that broke the ANY($1) batching (e.g. only resolving the +// first match) would surface as silently-stale names for every drive after +// the first. +func TestGetByVehicle_BatchesGeofenceNameResolution(t *testing.T) { + db := openRepairTestDB(t, repairDSNOrSkip(t)) + repo := NewDriveRepo(db) + vehicleID := seedRepairFixture(t, db) + + geofenceA := seedGeofenceNameFixture(t, db, "Place A") + geofenceB := seedGeofenceNameFixture(t, db, "Place B") + + insertDriveWithGeofence := func(startGeofenceID, endGeofenceID *int64, offset time.Duration) int64 { + var id int64 + err := db.Pool.QueryRow(context.Background(), ` +INSERT INTO drives (vehicle_id, started_at, start_place, end_place, start_geofence_id, end_geofence_id, place_label_version) +VALUES ($1, now() - $2::interval, 'fallback start', 'fallback end', $3, $4, $5) +RETURNING id`, vehicleID, fdur(offset), startGeofenceID, endGeofenceID, PlaceLabelVersion).Scan(&id) + if err != nil { + t.Fatalf("seed drive: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM drives WHERE id = $1`, id) + }) + return id + } + + driveA := insertDriveWithGeofence(&geofenceA, nil, time.Hour) + driveB := insertDriveWithGeofence(nil, &geofenceB, 2*time.Hour) + driveBoth := insertDriveWithGeofence(&geofenceA, &geofenceB, 3*time.Hour) + + drives, err := repo.GetByVehicle(context.Background(), vehicleID, 50, 0, time.Time{}, time.Time{}) + if err != nil { + t.Fatalf("GetByVehicle: %v", err) + } + byID := make(map[int64]*driveByIDResult, len(drives)) + for _, d := range drives { + byID[d.ID] = &driveByIDResult{startAddr: d.StartAddress, endAddr: d.EndAddress} + } + + if got := byID[driveA]; got == nil || got.startAddr == nil || *got.startAddr != "Place A" { + t.Errorf("driveA StartAddress = %v, want Place A", got) + } + if got := byID[driveB]; got == nil || got.endAddr == nil || *got.endAddr != "Place B" { + t.Errorf("driveB EndAddress = %v, want Place B", got) + } + if got := byID[driveBoth]; got == nil || got.startAddr == nil || *got.startAddr != "Place A" || got.endAddr == nil || *got.endAddr != "Place B" { + t.Errorf("driveBoth = %v, want start=Place A end=Place B", got) + } +} + +type driveByIDResult struct { + startAddr *string + endAddr *string +} + +// fdur renders a time.Duration as a Postgres interval literal understood by +// `::interval`. +func fdur(d time.Duration) string { + return d.String() +} diff --git a/internal/database/geofence/fakes_test.go b/internal/database/geofence/fakes_test.go index f936bb2440..beaf04b228 100644 --- a/internal/database/geofence/fakes_test.go +++ b/internal/database/geofence/fakes_test.go @@ -125,12 +125,16 @@ var _ geofencePool = (*fakePool)(nil) type fakeTx struct { pgx.Tx - execQueue []execResult + execQueue []execResult + queryQueue []queryResult + queryRowQueue []pgx.Row commitErr error rollbackErr error execCalls []recordedCall + queryCalls []recordedCall + queryRowCalls []recordedCall commitCalls int rollbackCalls int } @@ -147,6 +151,26 @@ func (t *fakeTx) Exec(_ context.Context, sql string, args ...any) (pgconn.Comman return pgconn.CommandTag{}, nil } +func (t *fakeTx) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { + t.queryCalls = append(t.queryCalls, recordedCall{sql: sql, args: cloneArgs(args)}) + if len(t.queryQueue) > 0 { + r := t.queryQueue[0] + t.queryQueue = t.queryQueue[1:] + return r.rows, r.err + } + return newFakeRows(nil), nil +} + +func (t *fakeTx) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { + t.queryRowCalls = append(t.queryRowCalls, recordedCall{sql: sql, args: cloneArgs(args)}) + if len(t.queryRowQueue) > 0 { + r := t.queryRowQueue[0] + t.queryRowQueue = t.queryRowQueue[1:] + return r + } + return fakeRow{} +} + func (t *fakeTx) Commit(_ context.Context) error { t.commitCalls++ return t.commitErr @@ -263,6 +287,16 @@ func geofenceRowVals(g *systemmodel.Geofence) []any { g.ID, g.Name, g.PolygonWKT, g.Category, g.Enabled, g.AlertOnEntry, g.AlertOnExit, g.CreatedAt, g.UpdatedAt, + g.Origin, g.NeedsReview, g.ArchivedAt, + } +} + +// geofenceRateRowVals renders a GeofenceRate into the column-ordered []any +// that scanGeofenceRate expects (see geofenceRateColumns). +func geofenceRateRowVals(gr *systemmodel.GeofenceRate) []any { + return []any{ + gr.ID, gr.GeofenceID, gr.RatePerWh, gr.Currency, + gr.EffectiveFrom, gr.EffectiveTo, gr.CreatedAt, } } diff --git a/internal/database/geofence/repo.go b/internal/database/geofence/repo.go index aab47df2a0..dd4714bf2a 100644 --- a/internal/database/geofence/repo.go +++ b/internal/database/geofence/repo.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "sort" "time" "github.com/ev-dev-labs/teslasync/internal/database" @@ -16,7 +17,7 @@ import ( // geofenceColumns is the canonical projection for every SELECT in this file. // Column order MUST match scanGeofence() arg order; keep them in sync or // pgx scan errors will surface at first list/get. -const geofenceColumns = `id, name, polygon_wkt, category, enabled, alert_on_entry, alert_on_exit, created_at, updated_at` +const geofenceColumns = `id, name, polygon_wkt, category, enabled, alert_on_entry, alert_on_exit, created_at, updated_at, origin, needs_review, archived_at` // scanGeofence is the single point of truth for geofences row → struct // mapping so a column rename only requires one edit. @@ -31,6 +32,9 @@ func scanGeofence(row pgx.Row, g *systemmodel.Geofence) error { &g.AlertOnExit, &g.CreatedAt, &g.UpdatedAt, + &g.Origin, + &g.NeedsReview, + &g.ArchivedAt, ) } @@ -44,21 +48,43 @@ func NewGeofenceRepo(db *database.DB) *GeofenceRepo { } func (r *GeofenceRepo) Create(ctx context.Context, g *systemmodel.Geofence) error { - query := `INSERT INTO geofences (name, polygon_wkt, category, enabled, alert_on_entry, alert_on_exit, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING id` + if g.Origin == "" { + g.Origin = systemmodel.GeofenceOriginManual + } + query := `INSERT INTO geofences (name, polygon_wkt, category, enabled, alert_on_entry, alert_on_exit, created_at, updated_at, origin, needs_review) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8, $9) RETURNING id` now := time.Now().UTC() if err := r.pool.QueryRow(ctx, query, g.Name, g.PolygonWKT, g.Category, g.Enabled, g.AlertOnEntry, g.AlertOnExit, - now, + now, g.Origin, g.NeedsReview, ).Scan(&g.ID); err != nil { return fmt.Errorf("geofences create: %w", err) } + g.CreatedAt, g.UpdatedAt = now, now return nil } +// GetAll lists active (non-archived) geofences. Archived places are excluded +// from this default listing per the historical-integrity rule — they remain +// individually resolvable via GetByID for history, but never appear in the +// general management list. func (r *GeofenceRepo) GetAll(ctx context.Context) ([]*systemmodel.Geofence, error) { - query := `SELECT ` + geofenceColumns + ` FROM geofences ORDER BY name LIMIT 500` + return r.getAll(ctx, false) +} + +// GetAllIncludingArchived lists active and archived geofences for management +// surfaces that explicitly request historical places. +func (r *GeofenceRepo) GetAllIncludingArchived(ctx context.Context) ([]*systemmodel.Geofence, error) { + return r.getAll(ctx, true) +} + +func (r *GeofenceRepo) getAll(ctx context.Context, includeArchived bool) ([]*systemmodel.Geofence, error) { + query := `SELECT ` + geofenceColumns + ` FROM geofences` + if !includeArchived { + query += ` WHERE archived_at IS NULL` + } + query += ` ORDER BY archived_at NULLS FIRST, name LIMIT 500` rows, err := r.pool.Query(ctx, query) if err != nil { return nil, fmt.Errorf("geofences get_all query: %w", err) @@ -92,20 +118,26 @@ func (r *GeofenceRepo) GetByID(ctx context.Context, id int64) (*systemmodel.Geof return g, nil } +// Update applies the CRUD/alert-relevant fields (name, geometry, category, +// enabled, alert flags, needs_review). It deliberately does NOT touch Origin +// (immutable provenance set once at creation) or ArchivedAt (owned +// exclusively by Archive/Unarchive so a routine merge-PUT can never +// accidentally resurrect or retire a place). func (r *GeofenceRepo) Update(ctx context.Context, g *systemmodel.Geofence) error { query := `UPDATE geofences SET name=$2, polygon_wkt=$3, category=$4, enabled=$5, alert_on_entry=$6, alert_on_exit=$7, - updated_at=$8 + needs_review=$8, updated_at=$9 WHERE id=$1` now := time.Now().UTC() if _, err := r.pool.Exec(ctx, query, g.ID, g.Name, g.PolygonWKT, g.Category, g.Enabled, g.AlertOnEntry, g.AlertOnExit, - now, + g.NeedsReview, now, ); err != nil { return fmt.Errorf("geofences update %d: %w", g.ID, err) } + g.UpdatedAt = now return nil } @@ -116,16 +148,37 @@ func (r *GeofenceRepo) Delete(ctx context.Context, id int64) error { return nil } -// FindByCoordinates finds geofences containing the given point. -// Loads all geofences and filters in Go using Haversine against the -// polygon centroid + derived radius (columns removed from table). +// rowQueryer is the minimal "run a SELECT" seam shared by geofencePool and +// pgx.Tx, letting findActiveGeofencesNear run identically inside or outside +// a transaction (needed by the discovery advisory-lock flow in +// repo_discovery.go, which must re-check for a match INSIDE its lock/tx). +type rowQueryer interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +// FindByCoordinates finds active (non-archived) geofences containing the +// given point. Loads all active geofences and filters in Go using Haversine +// against the polygon centroid + derived radius (columns removed from +// table). +// +// Archived places are excluded here (unlike GetByID, which resolves any ID +// including archived ones for history) because this method backs *active* +// matching: charging/drive place-name lookup and automation +// entry/exit-alert evaluation. Matching against a retired place identity +// would silently resurrect it from the user's perspective. // // NOTE: this intentionally does NOT filter on `enabled`. The reverse-geocoder // and friendly-name lookups want every fence; alert evaluators (FSM, // notification dispatcher) MUST filter g.Enabled themselves. func (r *GeofenceRepo) FindByCoordinates(ctx context.Context, lat, lng float64) ([]*systemmodel.Geofence, error) { - query := `SELECT ` + geofenceColumns + ` FROM geofences` - rows, err := r.pool.Query(ctx, query) + return findActiveGeofencesNear(ctx, r.pool, lat, lng) +} + +// findActiveGeofencesNear is the shared implementation behind +// FindByCoordinates; see that method's doc for behavior. +func findActiveGeofencesNear(ctx context.Context, q rowQueryer, lat, lng float64) ([]*systemmodel.Geofence, error) { + query := `SELECT ` + geofenceColumns + ` FROM geofences WHERE archived_at IS NULL` + rows, err := q.Query(ctx, query) if err != nil { return nil, fmt.Errorf("geofences find_by_coordinates query: %w", err) } @@ -146,6 +199,19 @@ func (r *GeofenceRepo) FindByCoordinates(ctx context.Context, lat, lng float64) if err := rows.Err(); err != nil { return nil, fmt.Errorf("geofences find_by_coordinates iter: %w", err) } + sort.SliceStable(geofences, func(i, j int) bool { + leftManual := geofences[i].Origin == systemmodel.GeofenceOriginManual + rightManual := geofences[j].Origin == systemmodel.GeofenceOriginManual + if leftManual != rightManual { + return leftManual + } + leftRadius := geofences[i].Radius() + rightRadius := geofences[j].Radius() + if leftRadius != rightRadius { + return leftRadius < rightRadius + } + return geofences[i].ID < geofences[j].ID + }) return geofences, nil } diff --git a/internal/database/geofence/repo_archive.go b/internal/database/geofence/repo_archive.go new file mode 100644 index 0000000000..d7802accfa --- /dev/null +++ b/internal/database/geofence/repo_archive.go @@ -0,0 +1,85 @@ +package geofence + +import ( + "context" + "fmt" +) + +// ============================================================================= +// repo_archive.go — soft-delete (archive/unarchive) for the charging-place +// pricing feature (migration 000228_geofence_charging_place_pricing). +// +// Historical-integrity rule: geofences with sessions/rates are archived +// rather than hard-deleted. Archived places are excluded from the default +// active listing (GetAll, FindByCoordinates, ListNeedsReview) but remain +// individually resolvable via GetByID for historical display (a past +// session's stored geofence_id must always resolve to *something*, even +// after the place is retired). +// ============================================================================= + +// Archive soft-deletes a geofence by stamping archived_at. Idempotent: +// archiving an already-archived geofence updates nothing (the original +// archive timestamp is preserved) and still returns success. +func (r *GeofenceRepo) Archive(ctx context.Context, id int64) error { + tag, err := r.pool.Exec(ctx, `UPDATE geofences SET archived_at = now(), updated_at = now() WHERE id = $1 AND archived_at IS NULL`, id) + if err != nil { + return fmt.Errorf("geofences archive %d: %w", id, err) + } + if tag.RowsAffected() == 0 { + // Either already archived (no-op success) or the id does not exist. + // Disambiguate with a lightweight existence check so callers get an + // honest 404 for a bad id while a re-archive stays a silent success. + g, err := r.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("geofences archive %d: %w", id, err) + } + if g == nil { + return fmt.Errorf("geofences archive %d: %w", id, ErrGeofenceNotFound) + } + } + return nil +} + +// Unarchive restores a previously-archived geofence back into the active +// listing. Idempotent: unarchiving an already-active geofence is a no-op +// success. +func (r *GeofenceRepo) Unarchive(ctx context.Context, id int64) error { + tag, err := r.pool.Exec(ctx, `UPDATE geofences SET archived_at = NULL, updated_at = now() WHERE id = $1 AND archived_at IS NOT NULL`, id) + if err != nil { + return fmt.Errorf("geofences unarchive %d: %w", id, err) + } + if tag.RowsAffected() == 0 { + g, err := r.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("geofences unarchive %d: %w", id, err) + } + if g == nil { + return fmt.Errorf("geofences unarchive %d: %w", id, ErrGeofenceNotFound) + } + } + return nil +} + +// HasChargingHistory reports whether any charging session, rate, or drive +// endpoint still references this geofence id. The handler layer uses this +// to decide whether a delete request must be redirected to Archive instead +// — geofence_id/rate_id/start_geofence_id/end_geofence_id have no DB-level +// FK (by design, to keep the telemetry hot path unblocked), so a hard +// DELETE on a referenced geofence would silently orphan those ids rather +// than fail loudly. A place with zero history can still be hard-deleted +// exactly as before this feature. +func (r *GeofenceRepo) HasChargingHistory(ctx context.Context, id int64) (bool, error) { + const q = ` +SELECT EXISTS ( + SELECT 1 FROM charging_sessions WHERE geofence_id = $1 + UNION ALL + SELECT 1 FROM geofence_rates WHERE geofence_id = $1 + UNION ALL + SELECT 1 FROM drives WHERE start_geofence_id = $1 OR end_geofence_id = $1 +)` + var exists bool + if err := r.pool.QueryRow(ctx, q, id).Scan(&exists); err != nil { + return false, fmt.Errorf("geofences has_charging_history %d: %w", id, err) + } + return exists, nil +} diff --git a/internal/database/geofence/repo_archive_test.go b/internal/database/geofence/repo_archive_test.go new file mode 100644 index 0000000000..49c1de0e1e --- /dev/null +++ b/internal/database/geofence/repo_archive_test.go @@ -0,0 +1,182 @@ +package geofence + +import ( + "context" + "errors" + "strings" + "testing" + + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------------- +// Archive +// --------------------------------------------------------------------------- + +func TestArchive(t *testing.T) { + t.Run("success stamps archived_at", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{tag: tag(1)}}} + if err := newRepo(pool).Archive(context.Background(), 1); err != nil { + t.Fatalf("unexpected err: %v", err) + } + call := pool.execCalls[0] + for _, sub := range []string{"archived_at = now()", "archived_at IS NULL"} { + if !strings.Contains(call.sql, sub) { + t.Errorf("SQL missing %q:\n%s", sub, call.sql) + } + } + // Idempotency check must not fire a second round trip on success. + if len(pool.queryRowCalls) != 0 { + t.Errorf("successful archive must not issue a GetByID lookup, got %d", len(pool.queryRowCalls)) + } + }) + + t.Run("already archived is idempotent success", func(t *testing.T) { + // 0 rows affected (already archived) but the id genuinely exists — + // GetByID must be consulted and return non-nil, and Archive must + // still report success (not an error). + pool := &fakePool{ + execQueue: []execResult{{tag: tag(0)}}, + queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(&archivedFixture)}}, + } + if err := newRepo(pool).Archive(context.Background(), 1); err != nil { + t.Fatalf("want idempotent success, got err: %v", err) + } + }) + + t.Run("nonexistent id returns ErrGeofenceNotFound", func(t *testing.T) { + pool := &fakePool{ + execQueue: []execResult{{tag: tag(0)}}, + queryRowQueue: []pgx.Row{noRow()}, + } + err := newRepo(pool).Archive(context.Background(), 999) + if !errors.Is(err, ErrGeofenceNotFound) { + t.Fatalf("err=%v, want ErrGeofenceNotFound", err) + } + }) + + t.Run("exec error wrapped", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{err: errBoom}}} + err := newRepo(pool).Archive(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) + + t.Run("existence-check error wrapped", func(t *testing.T) { + pool := &fakePool{ + execQueue: []execResult{{tag: tag(0)}}, + queryRowQueue: []pgx.Row{fakeRow{scanErr: errBoom}}, + } + err := newRepo(pool).Archive(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// Unarchive +// --------------------------------------------------------------------------- + +func TestUnarchive(t *testing.T) { + t.Run("success clears archived_at", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{tag: tag(1)}}} + if err := newRepo(pool).Unarchive(context.Background(), 1); err != nil { + t.Fatalf("unexpected err: %v", err) + } + call := pool.execCalls[0] + for _, sub := range []string{"archived_at = NULL", "archived_at IS NOT NULL"} { + if !strings.Contains(call.sql, sub) { + t.Errorf("SQL missing %q:\n%s", sub, call.sql) + } + } + }) + + t.Run("already active is idempotent success", func(t *testing.T) { + pool := &fakePool{ + execQueue: []execResult{{tag: tag(0)}}, + queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(&activeFixture)}}, + } + if err := newRepo(pool).Unarchive(context.Background(), 1); err != nil { + t.Fatalf("want idempotent success, got err: %v", err) + } + }) + + t.Run("nonexistent id returns ErrGeofenceNotFound", func(t *testing.T) { + pool := &fakePool{ + execQueue: []execResult{{tag: tag(0)}}, + queryRowQueue: []pgx.Row{noRow()}, + } + err := newRepo(pool).Unarchive(context.Background(), 999) + if !errors.Is(err, ErrGeofenceNotFound) { + t.Fatalf("err=%v, want ErrGeofenceNotFound", err) + } + }) + + t.Run("exec error wrapped", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{err: errBoom}}} + err := newRepo(pool).Unarchive(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// HasChargingHistory +// --------------------------------------------------------------------------- + +func TestHasChargingHistory(t *testing.T) { + t.Run("true when referenced", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: []any{true}}}} + got, err := newRepo(pool).HasChargingHistory(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !got { + t.Fatal("want true") + } + call := pool.queryRowCalls[0] + for _, sub := range []string{"charging_sessions WHERE geofence_id", "geofence_rates WHERE geofence_id", "start_geofence_id = $1 OR end_geofence_id = $1"} { + if !strings.Contains(call.sql, sub) { + t.Errorf("SQL missing %q:\n%s", sub, call.sql) + } + } + }) + + t.Run("false when unreferenced", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: []any{false}}}} + got, err := newRepo(pool).HasChargingHistory(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got { + t.Fatal("want false") + } + }) + + t.Run("query error wrapped", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{scanErr: errBoom}}} + _, err := newRepo(pool).HasChargingHistory(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +var archivedFixture = mustArchivedGeofence() +var activeFixture = mustActiveGeofence() + +func mustArchivedGeofence() systemmodel.Geofence { + g := systemmodel.Geofence{ID: 1, Name: "Retired Place", PolygonWKT: squareWKT(1, 1), CreatedAt: fixedTime, UpdatedAt: fixedTime} + t := fixedTime + g.ArchivedAt = &t + return g +} + +func mustActiveGeofence() systemmodel.Geofence { + return systemmodel.Geofence{ID: 1, Name: "Active Place", PolygonWKT: squareWKT(1, 1), CreatedAt: fixedTime, UpdatedAt: fixedTime} +} diff --git a/internal/database/geofence/repo_charging_summary.go b/internal/database/geofence/repo_charging_summary.go new file mode 100644 index 0000000000..43dce097ba --- /dev/null +++ b/internal/database/geofence/repo_charging_summary.go @@ -0,0 +1,419 @@ +package geofence + +import ( + "context" + "fmt" + "strings" + "time" + + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" +) + +// ============================================================================= +// repo_charging_summary.go — read-only charging-activity summaries plus the +// explicit preview/apply repricing flow for the charging-place pricing +// feature (migration 000228_geofence_charging_place_pricing). +// +// Cost precedence (highest to lowest): manual actual > Tesla-reported actual +// > geofence tariff > default/global estimate > unknown. PreviewApplyRate / +// ApplyRate never touch manual/Tesla-actual costs or an existing cost whose +// provenance is NULL/unknown. +// ============================================================================= + +// repriceEligibleCostSources lists sources that may be considered for +// repricing. NULL/unknown remain eligible only when cost_decimal is NULL; +// classifyRepriceCandidates protects an existing value with either source. +// "manual" and "tesla_actual" never appear here. +var repriceEligibleCostSources = map[string]bool{ + "": true, // NULL scanned into "" via COALESCE below + systemmodel.CostSourceGeofenceTariff: true, + systemmodel.CostSourceDefaultEstimate: true, + systemmodel.CostSourceUnknown: true, +} + +// ChargingSummaryByCurrency aggregates a geofence's PRICED charging activity +// (cost_decimal IS NOT NULL), grouped by currency. Different currencies are +// NEVER summed into a single total — a place that has seen both USD and EUR +// sessions (e.g. after a currency correction) returns two rows. +func (r *GeofenceRepo) ChargingSummaryByCurrency(ctx context.Context, geofenceID int64) ([]*systemmodel.GeofenceChargingSummary, error) { + const q = ` +SELECT cost_currency, + COUNT(*), + COALESCE(SUM(total_energy_added_wh), 0), + COALESCE(SUM(cost_decimal), 0) +FROM charging_sessions +WHERE geofence_id = $1 AND cost_decimal IS NOT NULL AND cost_currency IS NOT NULL +GROUP BY cost_currency +ORDER BY cost_currency` + rows, err := r.pool.Query(ctx, q, geofenceID) + if err != nil { + return nil, fmt.Errorf("geofence charging summary query: %w", err) + } + defer rows.Close() + + var out []*systemmodel.GeofenceChargingSummary + for rows.Next() { + s := &systemmodel.GeofenceChargingSummary{GeofenceID: geofenceID} + if err := rows.Scan(&s.Currency, &s.SessionCount, &s.TotalEnergyWh, &s.TotalCostDecimal); err != nil { + return nil, fmt.Errorf("geofence charging summary scan: %w", err) + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofence charging summary iter: %w", err) + } + return out, nil +} + +// ChargingActivity lists a geofence's charging sessions (any pricing state), +// newest first — the feed backing the rate-history / affected-sessions UI +// panels. limit is clamped to [1, 200]; offset floors at 0. +func (r *GeofenceRepo) ChargingActivity(ctx context.Context, geofenceID int64, limit, offset int) ([]*systemmodel.GeofenceChargingActivity, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + const q = ` +SELECT id, vehicle_id, started_at, ended_at, total_energy_added_wh, cost_decimal, cost_currency, cost_source, rate_id +FROM charging_sessions +WHERE geofence_id = $1 +ORDER BY started_at DESC +LIMIT $2 OFFSET $3` + rows, err := r.pool.Query(ctx, q, geofenceID, limit, offset) + if err != nil { + return nil, fmt.Errorf("geofence charging activity query: %w", err) + } + defer rows.Close() + + var out []*systemmodel.GeofenceChargingActivity + for rows.Next() { + a := &systemmodel.GeofenceChargingActivity{} + if err := rows.Scan(&a.SessionID, &a.VehicleID, &a.StartedAt, &a.EndedAt, &a.EnergyWh, &a.CostDecimal, &a.CostCurrency, &a.CostSource, &a.RateID); err != nil { + return nil, fmt.Errorf("geofence charging activity scan: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofence charging activity iter: %w", err) + } + return out, nil +} + +// repriceCandidate is the minimal projection needed to decide whether a +// charging session is in scope for a geofence rate preview/apply operation. +type repriceCandidate struct { + id int64 + geofenceID *int64 + startLat *float64 + startLng *float64 + startPlace *string + energyWh *float64 + costDecimal *float64 + costSource *string +} + +// loadRepriceCandidates loads every charging session started within +// [from, to) that is either already attributed to geofenceID or has no +// geofence attribution yet (geofence_id IS NULL — the historical-backfill +// case for sessions that predate this feature or predate this place's +// discovery). Spatial membership for the NULL-geofence_id rows is decided +// by the caller in Go (see classifyRepriceCandidates) since this codebase +// has no PostGIS geometry types, only WKT text + Go Haversine math. +func (r *GeofenceRepo) loadRepriceCandidates(ctx context.Context, geofenceID int64, from time.Time, to *time.Time) ([]repriceCandidate, error) { + query := ` +SELECT id, geofence_id, start_lat, start_lng, start_place, total_energy_added_wh, cost_decimal, cost_source +FROM charging_sessions +WHERE (geofence_id = $1 OR geofence_id IS NULL) + AND started_at >= $2` + args := []any{geofenceID, from} + if to != nil { + query += ` AND started_at < $3` + args = append(args, *to) + } + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("geofence reprice candidates query: %w", err) + } + defer rows.Close() + + var out []repriceCandidate + for rows.Next() { + var c repriceCandidate + if err := rows.Scan( + &c.id, + &c.geofenceID, + &c.startLat, + &c.startLng, + &c.startPlace, + &c.energyWh, + &c.costDecimal, + &c.costSource, + ); err != nil { + return nil, fmt.Errorf("geofence reprice candidates scan: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofence reprice candidates iter: %w", err) + } + return out, nil +} + +// classifyRepriceCandidates partitions loadRepriceCandidates' output into +// (matched, eligible, protected) against one geofence's circle: +// - matched: already attributed to this geofence, OR unattributed but +// spatially inside this geofence's circle. Legacy sessions without usable +// coordinates also match an identical saved start-place name; explicit +// preview/apply remains the write gate for this conservative fallback. +// - eligible (subset of matched): cost_source is geofence_tariff or +// default_estimate, or it is empty/unknown with no existing cost, AND +// total_energy_added_wh is known. These are the ids ApplyRate may write. +// - protected (subset of matched): cost_source is manual/tesla_actual, or +// an existing cost has empty/unknown provenance. They are surfaced +// separately so the UI can explain a preview total smaller than matched. +// +// A matched session with unknown energy (still charging, or otherwise +// incomplete) is neither eligible nor protected — it simply is not +// priceable yet; MatchedSessions - EligibleSessions - ProtectedSessions +// in the preview DTO represents exactly this remainder. +func classifyRepriceCandidates(candidates []repriceCandidate, g *systemmodel.Geofence) (matched, eligible, protected []repriceCandidate) { + cLat, cLon := g.Centroid() + radius := g.Radius() + for _, c := range candidates { + inScope := false + switch { + case c.geofenceID != nil && *c.geofenceID == g.ID: + inScope = true + case c.geofenceID == nil && + c.startLat != nil && + c.startLng != nil && + validCoordinate(*c.startLat, *c.startLng) && + radius > 0: + inScope = haversineMeters(*c.startLat, *c.startLng, cLat, cLon) <= radius + case c.geofenceID == nil && c.startPlace != nil && strings.TrimSpace(g.Name) != "": + inScope = strings.EqualFold(strings.TrimSpace(*c.startPlace), strings.TrimSpace(g.Name)) + } + if !inScope { + continue + } + matched = append(matched, c) + + source := "" + if c.costSource != nil { + source = *c.costSource + } + if source == systemmodel.CostSourceManual || source == systemmodel.CostSourceTeslaActual { + protected = append(protected, c) + continue + } + // Pre-feature rows can carry a real cost_decimal while cost_source is + // NULL. Unknown provenance must be protected rather than treated as + // unpriced, otherwise a historical backfill could overwrite an actual + // bill imported before provenance existed. + if c.costDecimal != nil && + (source == "" || source == systemmodel.CostSourceUnknown) { + protected = append(protected, c) + continue + } + if repriceEligibleCostSources[source] && c.energyWh != nil { + eligible = append(eligible, c) + } + } + return matched, eligible, protected +} + +// resolveApplyWindow resolves a GeofenceRateApplyScope + the target rate +// into a concrete [from, to) window: the rate's own effective interval, +// narrowed by the scope's optional From/To bounds (the later lower bound +// and the earlier non-nil upper bound win) — "Applying must be bounded to +// the selected geofence and interval" per the design rules. +func resolveApplyWindow(scope systemmodel.GeofenceRateApplyScope, rate *systemmodel.GeofenceRate) (time.Time, *time.Time) { + from := rate.EffectiveFrom + if scope.From != nil && scope.From.After(from) { + from = *scope.From + } + to := rate.EffectiveTo + if scope.To != nil && (to == nil || scope.To.Before(*to)) { + to = scope.To + } + return from, to +} + +// PreviewApplyRate computes — WITHOUT writing anything — the effect of +// applying scope.RateID to scope.GeofenceID's charging sessions. See +// classifyRepriceCandidates for exactly which sessions are matched/ +// eligible/protected. +func (r *GeofenceRepo) PreviewApplyRate(ctx context.Context, scope systemmodel.GeofenceRateApplyScope) (*systemmodel.GeofenceRateImpactPreview, error) { + g, rate, err := r.loadGeofenceAndRate(ctx, scope.GeofenceID, scope.RateID) + if err != nil { + return nil, err + } + + from, to := resolveApplyWindow(scope, rate) + candidates, err := r.loadRepriceCandidates(ctx, scope.GeofenceID, from, to) + if err != nil { + return nil, err + } + matched, eligible, protected := classifyRepriceCandidates(candidates, g) + + preview := &systemmodel.GeofenceRateImpactPreview{ + GeofenceID: scope.GeofenceID, + RateID: scope.RateID, + Currency: rate.Currency, + MatchedSessions: int64(len(matched)), + EligibleSessions: int64(len(eligible)), + ProtectedSessions: int64(len(protected)), + } + if len(eligible) == 0 { + return preview, nil + } + + ids := candidateIDs(eligible) + const q = ` +SELECT COALESCE(SUM(total_energy_added_wh), 0), + COALESCE(SUM(ROUND(total_energy_added_wh::numeric * $2::numeric, 6)), 0) +FROM charging_sessions +WHERE id = ANY($1)` + if err := r.pool.QueryRow(ctx, q, ids, rate.RatePerWh).Scan(&preview.TotalEnergyWh, &preview.EstimatedCostDecimal); err != nil { + return nil, fmt.Errorf("geofence rate preview aggregate: %w", err) + } + return preview, nil +} + +// ApplyRate is the write-performing counterpart of PreviewApplyRate. It +// (re)prices exactly the eligible session ids classifyRepriceCandidates +// selects, via one bounded UPDATE using PostgreSQL NUMERIC arithmetic +// (never Go float64 multiplication). Matched historical sessions without a +// geofence_id are attributed to the place in the same statement even when +// their manual, Tesla-actual, or unknown-provenance cost is protected. +// Re-running after a rate correction converges every eligible session to the +// current rate while protected monetary fields remain unchanged. +func (r *GeofenceRepo) ApplyRate(ctx context.Context, scope systemmodel.GeofenceRateApplyScope) (*systemmodel.GeofenceRateApplyResult, error) { + g, rate, err := r.loadGeofenceAndRate(ctx, scope.GeofenceID, scope.RateID) + if err != nil { + return nil, err + } + + from, to := resolveApplyWindow(scope, rate) + candidates, err := r.loadRepriceCandidates(ctx, scope.GeofenceID, from, to) + if err != nil { + return nil, err + } + matched, eligible, _ := classifyRepriceCandidates(candidates, g) + + result := &systemmodel.GeofenceRateApplyResult{ + GeofenceID: scope.GeofenceID, + RateID: scope.RateID, + Currency: rate.Currency, + MatchedSessions: int64(len(matched)), + SkippedSessions: int64(len(matched) - len(eligible)), + } + needsAttribution := false + for _, candidate := range matched { + if candidate.geofenceID == nil { + needsAttribution = true + break + } + } + if len(eligible) == 0 && !needsAttribution { + return result, nil + } + + matchedIDs := candidateIDs(matched) + eligibleIDs := candidateIDs(eligible) + const q = ` +WITH scoped AS ( + SELECT id, + total_energy_added_wh, + id = ANY($3) + AND total_energy_added_wh IS NOT NULL + AND ( + cost_source IN ('geofence_tariff', 'default_estimate') + OR (cost_source IS NULL AND cost_decimal IS NULL) + OR (cost_source = 'unknown' AND cost_decimal IS NULL) + ) AS should_price + FROM charging_sessions + WHERE id = ANY($1) + AND (geofence_id = $2 OR geofence_id IS NULL) +), +updated AS ( + UPDATE charging_sessions AS cs + SET geofence_id = $2, + cost_decimal = CASE + WHEN scoped.should_price THEN ROUND(cs.total_energy_added_wh::numeric * $4::numeric, 6) + ELSE cs.cost_decimal + END, + cost_currency = CASE WHEN scoped.should_price THEN $5 ELSE cs.cost_currency END, + rate_id = CASE WHEN scoped.should_price THEN $6 ELSE cs.rate_id END, + cost_source = CASE WHEN scoped.should_price THEN 'geofence_tariff' ELSE cs.cost_source END + FROM scoped + WHERE cs.id = scoped.id + RETURNING scoped.should_price, cs.total_energy_added_wh, cs.cost_decimal +) +SELECT total_energy_added_wh, cost_decimal + FROM updated + WHERE should_price` + rows, err := r.pool.Query( + ctx, + q, + matchedIDs, + scope.GeofenceID, + eligibleIDs, + rate.RatePerWh, + rate.Currency, + scope.RateID, + ) + if err != nil { + return nil, fmt.Errorf("geofence rate apply update: %w", err) + } + defer rows.Close() + var priced int64 + for rows.Next() { + var energyWh, cost float64 + if err := rows.Scan(&energyWh, &cost); err != nil { + return nil, fmt.Errorf("geofence rate apply scan: %w", err) + } + priced++ + result.TotalEnergyWh += energyWh + result.TotalCostDecimal += cost + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofence rate apply iter: %w", err) + } + result.PricedSessions = priced + result.SkippedSessions = int64(len(matched)) - priced + return result, nil +} + +// loadGeofenceAndRate fetches and validates the (geofence, rate) pair a +// preview/apply scope refers to, returning a descriptive error when either +// side is missing or the rate does not belong to the geofence. +func (r *GeofenceRepo) loadGeofenceAndRate(ctx context.Context, geofenceID, rateID int64) (*systemmodel.Geofence, *systemmodel.GeofenceRate, error) { + g, err := r.GetByID(ctx, geofenceID) + if err != nil { + return nil, nil, fmt.Errorf("load geofence %d: %w", geofenceID, err) + } + if g == nil { + return nil, nil, fmt.Errorf("load geofence %d: %w", geofenceID, ErrGeofenceNotFound) + } + rate, err := r.GetRateByID(ctx, geofenceID, rateID) + if err != nil { + return nil, nil, fmt.Errorf("load geofence rate %d: %w", rateID, err) + } + if rate == nil { + return nil, nil, fmt.Errorf("load geofence rate %d: %w", rateID, ErrRateNotFound) + } + return g, rate, nil +} + +// candidateIDs projects a []repriceCandidate down to its []int64 ids for +// the ANY($1) bind in the aggregate/UPDATE queries above. +func candidateIDs(cs []repriceCandidate) []int64 { + ids := make([]int64, len(cs)) + for i, c := range cs { + ids[i] = c.id + } + return ids +} diff --git a/internal/database/geofence/repo_charging_summary_test.go b/internal/database/geofence/repo_charging_summary_test.go new file mode 100644 index 0000000000..ea2929883f --- /dev/null +++ b/internal/database/geofence/repo_charging_summary_test.go @@ -0,0 +1,634 @@ +package geofence + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------------- +// ChargingSummaryByCurrency — multiple currencies must never be summed into +// one total: a place seen in both USD and EUR returns two independent rows. +// --------------------------------------------------------------------------- + +func TestChargingSummaryByCurrency(t *testing.T) { + t.Run("groups by currency, never sums across currencies", func(t *testing.T) { + rows := newFakeRows([][]any{ + {"USD", int64(10), 50_000.0, 12.5}, + {"EUR", int64(3), 9_000.0, 3.75}, + }) + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + + got, err := newRepo(pool).ChargingSummaryByCurrency(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 currency groups, got %d: %+v", len(got), got) + } + if got[0].Currency != "USD" || got[0].SessionCount != 10 || got[0].TotalEnergyWh != 50_000.0 || got[0].TotalCostDecimal != 12.5 { + t.Errorf("USD row mismatch: %+v", got[0]) + } + if got[1].Currency != "EUR" || got[1].SessionCount != 3 { + t.Errorf("EUR row mismatch: %+v", got[1]) + } + // Every row must carry the geofence id the caller asked about. + for _, s := range got { + if s.GeofenceID != 1 { + t.Errorf("GeofenceID not stamped: %+v", s) + } + } + call := pool.queryCalls[0] + for _, sub := range []string{"cost_decimal IS NOT NULL", "cost_currency IS NOT NULL", "GROUP BY cost_currency"} { + if !strings.Contains(call.sql, sub) { + t.Errorf("SQL missing %q:\n%s", sub, call.sql) + } + } + }) + + t.Run("query error wrapped", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{err: errBoom}}} + _, err := newRepo(pool).ChargingSummaryByCurrency(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) + + t.Run("scan error wrapped", func(t *testing.T) { + rows := newFakeRows([][]any{{"USD", int64(1), 1.0, 1.0}}) + rows.scanErrAt = 0 + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + _, err := newRepo(pool).ChargingSummaryByCurrency(context.Background(), 1) + if err == nil || !strings.Contains(err.Error(), "scan") { + t.Fatalf("err=%v, want scan error", err) + } + }) +} + +// --------------------------------------------------------------------------- +// ChargingActivity — pagination clamping +// --------------------------------------------------------------------------- + +func TestChargingActivity(t *testing.T) { + t.Run("passes through valid limit/offset", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows(nil)}}} + _, err := newRepo(pool).ChargingActivity(context.Background(), 1, 25, 10) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + call := pool.queryCalls[0] + if call.args[1] != 25 || call.args[2] != 10 { + t.Errorf("args: want [1 25 10], got %v", call.args) + } + }) + + t.Run("non-positive limit clamps to default 50", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows(nil)}}} + _, _ = newRepo(pool).ChargingActivity(context.Background(), 1, 0, 0) + if pool.queryCalls[0].args[1] != 50 { + t.Errorf("limit not clamped to 50: %v", pool.queryCalls[0].args[1]) + } + }) + + t.Run("over-cap limit clamps to default 50", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows(nil)}}} + _, _ = newRepo(pool).ChargingActivity(context.Background(), 1, 500, 0) + if pool.queryCalls[0].args[1] != 50 { + t.Errorf("over-cap limit not clamped to 50: %v", pool.queryCalls[0].args[1]) + } + }) + + t.Run("negative offset floors at 0", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows(nil)}}} + _, _ = newRepo(pool).ChargingActivity(context.Background(), 1, 10, -5) + if pool.queryCalls[0].args[2] != 0 { + t.Errorf("negative offset not floored: %v", pool.queryCalls[0].args[2]) + } + }) + + t.Run("scans full activity row shape", func(t *testing.T) { + ended := fixedTime.Add(time.Hour) + energy := 12_000.0 + cost := 1.25 + currency := "USD" + source := systemmodel.CostSourceGeofenceTariff + rateID := int64(9) + rows := newFakeRows([][]any{ + {int64(100), int64(1), fixedTime, &ended, &energy, &cost, ¤cy, &source, &rateID}, + }) + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + got, err := newRepo(pool).ChargingActivity(context.Background(), 1, 10, 0) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 || got[0].SessionID != 100 || got[0].CostSource == nil || *got[0].CostSource != systemmodel.CostSourceGeofenceTariff { + t.Fatalf("unexpected activity row: %+v", got) + } + }) + + t.Run("query error wrapped", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{err: errBoom}}} + _, err := newRepo(pool).ChargingActivity(context.Background(), 1, 10, 0) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// resolveApplyWindow +// --------------------------------------------------------------------------- + +func TestResolveApplyWindow(t *testing.T) { + rateFrom := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + rateTo := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC) + + t.Run("no scope bounds uses the rate's own interval", func(t *testing.T) { + rate := &systemmodel.GeofenceRate{EffectiveFrom: rateFrom, EffectiveTo: &rateTo} + from, to := resolveApplyWindow(systemmodel.GeofenceRateApplyScope{}, rate) + if !from.Equal(rateFrom) || to == nil || !to.Equal(rateTo) { + t.Fatalf("from=%v to=%v, want rate's own interval", from, to) + } + }) + + t.Run("scope.From earlier than rate.EffectiveFrom is ignored (later lower bound wins)", func(t *testing.T) { + earlier := rateFrom.Add(-24 * time.Hour) + rate := &systemmodel.GeofenceRate{EffectiveFrom: rateFrom} + from, _ := resolveApplyWindow(systemmodel.GeofenceRateApplyScope{From: &earlier}, rate) + if !from.Equal(rateFrom) { + t.Fatalf("from=%v, want rate.EffectiveFrom (later bound) to win", from) + } + }) + + t.Run("scope.From later than rate.EffectiveFrom narrows the window", func(t *testing.T) { + later := rateFrom.Add(24 * time.Hour) + rate := &systemmodel.GeofenceRate{EffectiveFrom: rateFrom} + from, _ := resolveApplyWindow(systemmodel.GeofenceRateApplyScope{From: &later}, rate) + if !from.Equal(later) { + t.Fatalf("from=%v, want scope.From (narrower) to win", from) + } + }) + + t.Run("open rate + scope.To narrows to scope.To", func(t *testing.T) { + rate := &systemmodel.GeofenceRate{EffectiveFrom: rateFrom} // EffectiveTo nil (open) + bound := rateFrom.Add(48 * time.Hour) + _, to := resolveApplyWindow(systemmodel.GeofenceRateApplyScope{To: &bound}, rate) + if to == nil || !to.Equal(bound) { + t.Fatalf("to=%v, want scope.To since the rate itself is open", to) + } + }) + + t.Run("scope.To earlier than rate.EffectiveTo narrows the window", func(t *testing.T) { + earlier := rateTo.Add(-24 * time.Hour) + rate := &systemmodel.GeofenceRate{EffectiveFrom: rateFrom, EffectiveTo: &rateTo} + _, to := resolveApplyWindow(systemmodel.GeofenceRateApplyScope{To: &earlier}, rate) + if to == nil || !to.Equal(earlier) { + t.Fatalf("to=%v, want the earlier scope.To to win", to) + } + }) + + t.Run("scope.To later than rate.EffectiveTo is ignored (earlier upper bound wins)", func(t *testing.T) { + later := rateTo.Add(24 * time.Hour) + rate := &systemmodel.GeofenceRate{EffectiveFrom: rateFrom, EffectiveTo: &rateTo} + _, to := resolveApplyWindow(systemmodel.GeofenceRateApplyScope{To: &later}, rate) + if to == nil || !to.Equal(rateTo) { + t.Fatalf("to=%v, want rate.EffectiveTo (earlier bound) to win", to) + } + }) +} + +// --------------------------------------------------------------------------- +// classifyRepriceCandidates — the core matched/eligible/protected split that +// guarantees a manual or Tesla-actual cost is NEVER touched by repricing, +// and an out-of-scope session is never even considered. +// --------------------------------------------------------------------------- + +func testGeofenceAt(id int64, lat, lon float64) *systemmodel.Geofence { + return &systemmodel.Geofence{ID: id, Name: "Test Place", PolygonWKT: squareWKT(lat, lon)} +} + +func f64ptr(v float64) *float64 { return &v } +func strptr(v string) *string { return &v } +func TestClassifyRepriceCandidates(t *testing.T) { + g := testGeofenceAt(1, 37.7749, -122.4194) + cLat, cLon := g.Centroid() + + candidates := []repriceCandidate{ + // 1: already attributed to this geofence, no cost yet, has energy -> eligible. + {id: 1, geofenceID: int64Ptr(1), energyWh: f64ptr(5000)}, + // 2: unattributed but spatially inside -> matched + eligible (default_estimate). + {id: 2, startLat: f64ptr(cLat), startLng: f64ptr(cLon), energyWh: f64ptr(3000), costSource: strptr(systemmodel.CostSourceDefaultEstimate)}, + // 3: unattributed and spatially far away (~1 degree ~111km) -> never matched. + {id: 3, startLat: f64ptr(cLat + 1), startLng: f64ptr(cLon), energyWh: f64ptr(1000)}, + // 4: attributed to this geofence but manual cost -> matched + protected, never eligible. + {id: 4, geofenceID: int64Ptr(1), energyWh: f64ptr(2000), costSource: strptr(systemmodel.CostSourceManual)}, + // 5: attributed to this geofence but tesla_actual cost -> matched + protected. + {id: 5, geofenceID: int64Ptr(1), energyWh: f64ptr(2000), costSource: strptr(systemmodel.CostSourceTeslaActual)}, + // 6: attributed to this geofence, eligible cost_source, but NO energy yet + // (still charging / incomplete) -> matched, but neither eligible nor protected. + {id: 6, geofenceID: int64Ptr(1), costSource: strptr(systemmodel.CostSourceUnknown)}, + // 7: attributed to a DIFFERENT geofence -> never matched, even though it's "in the list". + {id: 7, geofenceID: int64Ptr(2), energyWh: f64ptr(4000)}, + // 8: unattributed, no lat/lng at all -> never matched (can't spatially resolve). + {id: 8, energyWh: f64ptr(1000)}, + // 9: already attributed to this geofence, previously geofence_tariff-priced -> eligible (repriced again). + {id: 9, geofenceID: int64Ptr(1), energyWh: f64ptr(6000), costSource: strptr(systemmodel.CostSourceGeofenceTariff)}, + // 10: pre-feature row with a real cost but no provenance -> protected. + {id: 10, geofenceID: int64Ptr(1), energyWh: f64ptr(6000), costDecimal: f64ptr(7.25)}, + // 11: legacy unattributed row without coordinates but with an exact saved place name -> eligible. + {id: 11, startPlace: strptr(" test PLACE "), energyWh: f64ptr(2500)}, + // 12: a different legacy place name must not match. + {id: 12, startPlace: strptr("Different Place"), energyWh: f64ptr(2500)}, + // 13: placeholder (0,0) coordinates are unusable, so the exact-name fallback applies. + {id: 13, startLat: f64ptr(0), startLng: f64ptr(0), startPlace: strptr("Test Place"), energyWh: f64ptr(1500)}, + } + + matched, eligible, protected := classifyRepriceCandidates(candidates, g) + + assertIDs := func(t *testing.T, label string, got []repriceCandidate, want []int64) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: got %d ids, want %d (got=%v want=%v)", label, len(got), len(want), idsOf(got), want) + } + for i, c := range got { + if c.id != want[i] { + t.Fatalf("%s[%d]=%d, want %d (full got=%v)", label, i, c.id, want[i], idsOf(got)) + } + } + } + + assertIDs(t, "matched", matched, []int64{1, 2, 4, 5, 6, 9, 10, 11, 13}) + assertIDs(t, "eligible", eligible, []int64{1, 2, 9, 11, 13}) + assertIDs(t, "protected", protected, []int64{4, 5, 10}) + + // The remainder (matched - eligible - protected) must be exactly {6}: + // in-scope by place, but not yet priceable because energy is unknown. + remainder := len(matched) - len(eligible) - len(protected) + if remainder != 1 { + t.Fatalf("remainder = %d, want 1 (session #6, matched but not priceable yet)", remainder) + } +} + +func idsOf(cs []repriceCandidate) []int64 { + ids := make([]int64, len(cs)) + for i, c := range cs { + ids[i] = c.id + } + return ids +} + +func int64Ptr(v int64) *int64 { return &v } + +// --------------------------------------------------------------------------- +// loadRepriceCandidates — query shape +// --------------------------------------------------------------------------- + +func TestLoadRepriceCandidates(t *testing.T) { + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run("open window omits the upper bound clause", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows(nil)}}} + _, err := newRepo(pool).loadRepriceCandidates(context.Background(), 1, from, nil) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + call := pool.queryCalls[0] + if strings.Contains(call.sql, "started_at < $3") { + t.Errorf("open window must not bind an upper bound: %s", call.sql) + } + if len(call.args) != 2 { + t.Fatalf("want 2 args for an open window, got %v", call.args) + } + }) + + t.Run("closed window adds the upper bound clause", func(t *testing.T) { + to := from.Add(30 * 24 * time.Hour) + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows(nil)}}} + _, err := newRepo(pool).loadRepriceCandidates(context.Background(), 1, from, &to) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + call := pool.queryCalls[0] + if !strings.Contains(call.sql, "started_at < $3") { + t.Errorf("closed window must bind an upper bound: %s", call.sql) + } + if len(call.args) != 3 || call.args[2] != to { + t.Fatalf("args mismatch: %v", call.args) + } + }) + + t.Run("matches unattributed OR this geofence", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows(nil)}}} + _, _ = newRepo(pool).loadRepriceCandidates(context.Background(), 1, from, nil) + if !strings.Contains(pool.queryCalls[0].sql, "geofence_id = $1 OR geofence_id IS NULL") { + t.Errorf("unexpected SQL: %s", pool.queryCalls[0].sql) + } + }) + + t.Run("query error wrapped", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{err: errBoom}}} + _, err := newRepo(pool).loadRepriceCandidates(context.Background(), 1, from, nil) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) + + t.Run("scan error wrapped", func(t *testing.T) { + rows := newFakeRows([][]any{{int64(1), (*int64)(nil), (*float64)(nil), (*float64)(nil), (*string)(nil), (*float64)(nil), (*float64)(nil), (*string)(nil)}}) + rows.scanErrAt = 0 + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + _, err := newRepo(pool).loadRepriceCandidates(context.Background(), 1, from, nil) + if err == nil || !strings.Contains(err.Error(), "scan") { + t.Fatalf("err=%v, want scan error", err) + } + }) +} + +// --------------------------------------------------------------------------- +// candidateIDs +// --------------------------------------------------------------------------- + +func TestCandidateIDs(t *testing.T) { + got := candidateIDs([]repriceCandidate{{id: 3}, {id: 1}, {id: 2}}) + want := []int64{3, 1, 2} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +// --------------------------------------------------------------------------- +// loadGeofenceAndRate +// --------------------------------------------------------------------------- + +func TestLoadGeofenceAndRate(t *testing.T) { + g := &systemmodel.Geofence{ID: 1, Name: "Home", PolygonWKT: squareWKT(1, 1), CreatedAt: fixedTime, UpdatedAt: fixedTime} + rt := &systemmodel.GeofenceRate{ID: 9, GeofenceID: 1, RatePerWh: 0.0001, Currency: "USD", EffectiveFrom: fixedTime, CreatedAt: fixedTime} + + t.Run("success", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, fakeRow{vals: geofenceRateRowVals(rt)}}} + gotG, gotR, err := newRepo(pool).loadGeofenceAndRate(context.Background(), 1, 9) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if gotG.ID != 1 || gotR.ID != 9 { + t.Fatalf("got g=%+v r=%+v", gotG, gotR) + } + }) + + t.Run("missing geofence", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{noRow()}} + _, _, err := newRepo(pool).loadGeofenceAndRate(context.Background(), 999, 9) + if !errors.Is(err, ErrGeofenceNotFound) { + t.Fatalf("err=%v, want ErrGeofenceNotFound", err) + } + }) + + t.Run("missing rate", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, noRow()}} + _, _, err := newRepo(pool).loadGeofenceAndRate(context.Background(), 1, 999) + if !errors.Is(err, ErrRateNotFound) { + t.Fatalf("err=%v, want ErrRateNotFound", err) + } + }) +} + +// --------------------------------------------------------------------------- +// PreviewApplyRate / ApplyRate — end-to-end through the fake pool. +// --------------------------------------------------------------------------- + +// scenarioCandidatesRows builds the loadRepriceCandidates row shape for one +// eligible, in-scope session (id=10, unattributed-but-inside, no prior cost) +// plus one protected session (id=11, manual cost) so Preview/Apply must +// report MatchedSessions=2, EligibleSessions/PricedSessions=1, +// ProtectedSessions=1/SkippedSessions=1. +func scenarioCandidatesRows(g *systemmodel.Geofence) [][]any { + cLat, cLon := g.Centroid() + manual := systemmodel.CostSourceManual + return [][]any{ + {int64(10), (*int64)(nil), &cLat, &cLon, (*string)(nil), f64ptr(10_000), (*float64)(nil), (*string)(nil)}, + {int64(11), int64Ptr(g.ID), (*float64)(nil), (*float64)(nil), (*string)(nil), f64ptr(5_000), f64ptr(4.25), &manual}, + } +} + +func TestPreviewApplyRate(t *testing.T) { + g := &systemmodel.Geofence{ID: 1, Name: "Home", PolygonWKT: squareWKT(37.7749, -122.4194), CreatedAt: fixedTime, UpdatedAt: fixedTime} + rt := &systemmodel.GeofenceRate{ID: 9, GeofenceID: 1, RatePerWh: 0.0001005, Currency: "USD", EffectiveFrom: fixedTime, CreatedAt: fixedTime} + + t.Run("aggregates only the eligible subset", func(t *testing.T) { + pool := &fakePool{ + queryRowQueue: []pgx.Row{ + fakeRow{vals: geofenceRowVals(g)}, // loadGeofenceAndRate: GetByID + fakeRow{vals: geofenceRateRowVals(rt)}, // loadGeofenceAndRate: GetRateByID + fakeRow{vals: []any{10_000.0, 1.005}}, // aggregate SUM + }, + queryQueue: []queryResult{{rows: newFakeRows(scenarioCandidatesRows(g))}}, + } + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 1, RateID: 9} + preview, err := newRepo(pool).PreviewApplyRate(context.Background(), scope) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if preview.MatchedSessions != 2 { + t.Errorf("MatchedSessions=%d, want 2", preview.MatchedSessions) + } + if preview.EligibleSessions != 1 { + t.Errorf("EligibleSessions=%d, want 1", preview.EligibleSessions) + } + if preview.ProtectedSessions != 1 { + t.Errorf("ProtectedSessions=%d, want 1", preview.ProtectedSessions) + } + if preview.Currency != "USD" { + t.Errorf("Currency=%q, want USD", preview.Currency) + } + if preview.TotalEnergyWh != 10_000.0 || preview.EstimatedCostDecimal != 1.005 { + t.Errorf("aggregate mismatch: %+v", preview) + } + // The aggregate query must scope to exactly the eligible ids (10), + // never the protected one (11). + aggCall := pool.queryRowCalls[2] + ids, ok := aggCall.args[0].([]int64) + if !ok || len(ids) != 1 || ids[0] != 10 { + t.Errorf("aggregate ids: want [10], got %v", aggCall.args[0]) + } + }) + + t.Run("zero eligible sessions skips the aggregate round trip", func(t *testing.T) { + manual := systemmodel.CostSourceManual + onlyProtected := [][]any{{int64(11), int64Ptr(g.ID), (*float64)(nil), (*float64)(nil), (*string)(nil), f64ptr(5_000), f64ptr(4.25), &manual}} + pool := &fakePool{ + queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, fakeRow{vals: geofenceRateRowVals(rt)}}, + queryQueue: []queryResult{{rows: newFakeRows(onlyProtected)}}, + } + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 1, RateID: 9} + preview, err := newRepo(pool).PreviewApplyRate(context.Background(), scope) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if preview.EligibleSessions != 0 || preview.EstimatedCostDecimal != 0 { + t.Fatalf("want zero eligible/cost, got %+v", preview) + } + if len(pool.queryRowCalls) != 2 { + t.Fatalf("must skip the aggregate QueryRow when nothing is eligible, got %d calls", len(pool.queryRowCalls)) + } + }) + + t.Run("missing geofence propagates ErrGeofenceNotFound", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{noRow()}} + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 999, RateID: 9} + _, err := newRepo(pool).PreviewApplyRate(context.Background(), scope) + if !errors.Is(err, ErrGeofenceNotFound) { + t.Fatalf("err=%v, want ErrGeofenceNotFound", err) + } + }) +} + +func TestApplyRate(t *testing.T) { + g := &systemmodel.Geofence{ID: 1, Name: "Home", PolygonWKT: squareWKT(37.7749, -122.4194), CreatedAt: fixedTime, UpdatedAt: fixedTime} + rt := &systemmodel.GeofenceRate{ID: 9, GeofenceID: 1, RatePerWh: 0.0001005, Currency: "USD", EffectiveFrom: fixedTime, CreatedAt: fixedTime} + + t.Run("prices only the eligible subset, never the protected one", func(t *testing.T) { + updateRows := newFakeRows([][]any{{10_000.0, 1.005}}) // one UPDATE...RETURNING row (session 10 only) + pool := &fakePool{ + queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, fakeRow{vals: geofenceRateRowVals(rt)}}, + queryQueue: []queryResult{ + {rows: newFakeRows(scenarioCandidatesRows(g))}, // loadRepriceCandidates + {rows: updateRows}, // UPDATE ... RETURNING + }, + } + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 1, RateID: 9} + result, err := newRepo(pool).ApplyRate(context.Background(), scope) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if result.MatchedSessions != 2 { + t.Errorf("MatchedSessions=%d, want 2", result.MatchedSessions) + } + if result.PricedSessions != 1 { + t.Errorf("PricedSessions=%d, want 1", result.PricedSessions) + } + if result.SkippedSessions != 1 { + t.Errorf("SkippedSessions=%d, want 1 (the protected manual-cost session)", result.SkippedSessions) + } + if result.TotalEnergyWh != 10_000.0 || result.TotalCostDecimal != 1.005 { + t.Errorf("totals mismatch: %+v", result) + } + updateCall := pool.queryCalls[1] + for _, sub := range []string{ + "UPDATE charging_sessions", + "cost_source = CASE WHEN scoped.should_price THEN 'geofence_tariff' ELSE cs.cost_source END", + "cost_source IS NULL AND cost_decimal IS NULL", + } { + if !strings.Contains(updateCall.sql, sub) { + t.Errorf("UPDATE SQL missing %q:\n%s", sub, updateCall.sql) + } + } + matchedIDs, ok := updateCall.args[0].([]int64) + if !ok || len(matchedIDs) != 2 || matchedIDs[0] != 10 || matchedIDs[1] != 11 { + t.Errorf("matched UPDATE ids: want [10 11], got %v", updateCall.args[0]) + } + eligibleIDs, ok := updateCall.args[2].([]int64) + if !ok || len(eligibleIDs) != 1 || eligibleIDs[0] != 10 { + t.Errorf("eligible UPDATE ids: want [10] (never reprice protected id 11), got %v", updateCall.args[2]) + } + }) + + t.Run("zero eligible sessions skips the UPDATE round trip", func(t *testing.T) { + manual := systemmodel.CostSourceManual + onlyProtected := [][]any{{int64(11), int64Ptr(g.ID), (*float64)(nil), (*float64)(nil), (*string)(nil), f64ptr(5_000), f64ptr(4.25), &manual}} + pool := &fakePool{ + queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, fakeRow{vals: geofenceRateRowVals(rt)}}, + queryQueue: []queryResult{{rows: newFakeRows(onlyProtected)}}, + } + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 1, RateID: 9} + result, err := newRepo(pool).ApplyRate(context.Background(), scope) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if result.PricedSessions != 0 || result.SkippedSessions != 1 { + t.Fatalf("want 0 priced / 1 skipped, got %+v", result) + } + if len(pool.queryCalls) != 1 { + t.Fatalf("must skip the UPDATE when nothing is eligible, got %d Query calls", len(pool.queryCalls)) + } + }) + + t.Run("attributes an unattributed protected session without repricing it", func(t *testing.T) { + cLat, cLon := g.Centroid() + manual := systemmodel.CostSourceManual + unattributedProtected := [][]any{ + {int64(11), (*int64)(nil), &cLat, &cLon, (*string)(nil), f64ptr(5_000), f64ptr(4.25), &manual}, + } + pool := &fakePool{ + queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, fakeRow{vals: geofenceRateRowVals(rt)}}, + queryQueue: []queryResult{ + {rows: newFakeRows(unattributedProtected)}, + {rows: newFakeRows(nil)}, + }, + } + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 1, RateID: 9} + result, err := newRepo(pool).ApplyRate(context.Background(), scope) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if result.PricedSessions != 0 || result.SkippedSessions != 1 { + t.Fatalf("want protected cost skipped while place is attributed, got %+v", result) + } + if len(pool.queryCalls) != 2 { + t.Fatalf("want candidate SELECT plus attribution UPDATE, got %d Query calls", len(pool.queryCalls)) + } + updateCall := pool.queryCalls[1] + if !strings.Contains(updateCall.sql, "SET geofence_id = $2") { + t.Fatalf("UPDATE must attach the historical session to the place:\n%s", updateCall.sql) + } + eligibleIDs, ok := updateCall.args[2].([]int64) + if !ok || len(eligibleIDs) != 0 { + t.Fatalf("protected session must not be eligible for repricing, got %v", updateCall.args[2]) + } + }) + + t.Run("idempotent: re-running after all sessions already priced prices zero more", func(t *testing.T) { + // Second run: the UPDATE's own WHERE guard (cost_source already + // geofence_tariff is still eligible, so this models a scenario + // where nothing further changes) returns zero rows because the + // classifier decided there was nothing new to do — modeled here by + // an eligible candidate set that yields an UPDATE returning zero + // rows (e.g. concurrent apply already converged it to a manual + // cost between classification and the UPDATE). + alreadyDone := [][]any{{int64(10), int64Ptr(g.ID), (*float64)(nil), (*float64)(nil), (*string)(nil), f64ptr(10_000), f64ptr(1.005), strptr(systemmodel.CostSourceGeofenceTariff)}} + pool := &fakePool{ + queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, fakeRow{vals: geofenceRateRowVals(rt)}}, + queryQueue: []queryResult{ + {rows: newFakeRows(alreadyDone)}, + {rows: newFakeRows(nil)}, // UPDATE matched 0 rows this time + }, + } + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 1, RateID: 9} + result, err := newRepo(pool).ApplyRate(context.Background(), scope) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if result.PricedSessions != 0 { + t.Fatalf("want 0 priced, got %d", result.PricedSessions) + } + }) + + t.Run("missing rate propagates ErrRateNotFound", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRowVals(g)}, noRow()}} + scope := systemmodel.GeofenceRateApplyScope{GeofenceID: 1, RateID: 999} + _, err := newRepo(pool).ApplyRate(context.Background(), scope) + if !errors.Is(err, ErrRateNotFound) { + t.Fatalf("err=%v, want ErrRateNotFound", err) + } + }) +} diff --git a/internal/database/geofence/repo_discovery.go b/internal/database/geofence/repo_discovery.go new file mode 100644 index 0000000000..2ac49f32fa --- /dev/null +++ b/internal/database/geofence/repo_discovery.go @@ -0,0 +1,279 @@ +package geofence + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" +) + +// ============================================================================= +// repo_discovery.go — auto-discovery of charging-place geofences from +// confirmed charging sessions (migration +// 000228_geofence_charging_place_pricing). +// +// This file is invoked from the async post-commit telemetry leg and the +// one-shot startup legacy-history backfill — never from the MQTT/SignalStore +// hot path — so discovery latency or a transient DB failure cannot trigger +// telemetry redelivery or block ingest. +// ============================================================================= + +// DiscoveryRadiusMeters is the fixed radius (in meters) of a provisional +// geofence synthesized by FindOrCreateForCharging. 75m comfortably covers a +// Supercharger stall's or driveway's GPS jitter without swallowing an +// unrelated nearby place, and matches the business requirement exactly. +const DiscoveryRadiusMeters = 75.0 + +// geofenceDiscoveryLockKey is the single global PostgreSQL advisory-lock +// key serializing "match-or-create a provisional charging-place geofence" +// across all concurrent callers (e.g. two vehicles finishing a charge at +// the same moment, or a retried async attempt). Charge-completion discovery +// is inherently low-frequency (bounded by concurrently-completing charge +// sessions, not by telemetry tick volume), so a single global lock is +// simpler and strictly more correct than a coordinate-grid-bucketed lock +// (which has boundary edge cases where two nearby points hash to different +// cells and race each other). The lock is transaction-scoped +// (pg_advisory_xact_lock) so it always releases at commit/rollback/crash — +// it can never be leaked or require manual cleanup. +const geofenceDiscoveryLockKey = "geofence:discovery" + +// ListChargingPlaceBackfillCandidates returns one cursor-paginated batch of +// completed legacy charging sessions that have usable start coordinates but +// no geofence attribution yet. The ID cursor ensures a bad row cannot make a +// startup pass spin forever; failed rows remain eligible for the next boot. +func (r *GeofenceRepo) ListChargingPlaceBackfillCandidates(ctx context.Context, afterID int64, limit int) ([]*systemmodel.ChargingPlaceBackfillCandidate, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + const query = ` +SELECT id, vehicle_id, started_at, start_lat, start_lng, start_place +FROM charging_sessions +WHERE id > $1 + AND geofence_id IS NULL + AND ended_at IS NOT NULL + AND start_lat IS NOT NULL + AND start_lng IS NOT NULL + AND start_lat BETWEEN -90 AND 90 + AND start_lng BETWEEN -180 AND 180 + AND NOT (start_lat = 0 AND start_lng = 0) +ORDER BY id +LIMIT $2` + rows, err := r.pool.Query(ctx, query, afterID, limit) + if err != nil { + return nil, fmt.Errorf("geofence charging backfill candidates query: %w", err) + } + defer rows.Close() + + var out []*systemmodel.ChargingPlaceBackfillCandidate + for rows.Next() { + candidate := &systemmodel.ChargingPlaceBackfillCandidate{} + if err := rows.Scan( + &candidate.SessionID, + &candidate.VehicleID, + &candidate.StartedAt, + &candidate.StartLat, + &candidate.StartLng, + &candidate.StartPlace, + ); err != nil { + return nil, fmt.Errorf("geofence charging backfill candidates scan: %w", err) + } + out = append(out, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofence charging backfill candidates iter: %w", err) + } + return out, nil +} + +// ApplyCurrentRateEstimate prices one completed legacy session with the rate +// active at `at` only when no configured rate covers the session's started_at. +// The provenance is default_estimate rather than geofence_tariff so a later +// explicit historical-rate apply can replace it. Actual/manual/unknown costs +// and estimates already pinned to another rate remain untouched. +func (r *GeofenceRepo) ApplyCurrentRateEstimate(ctx context.Context, sessionID, geofenceID, rateID int64, at time.Time) (bool, error) { + const query = ` +UPDATE charging_sessions AS cs + SET cost_decimal = ROUND(cs.total_energy_added_wh::numeric * rate.rate_per_wh, 6), + cost_currency = rate.currency, + rate_id = rate.id, + cost_source = 'default_estimate' + FROM geofence_rates AS rate + WHERE cs.id = $1 + AND cs.geofence_id = $2 + AND cs.ended_at IS NOT NULL + AND cs.total_energy_added_wh IS NOT NULL + AND rate.id = $3 + AND rate.geofence_id = $2 + AND rate.effective_from <= $4 + AND (rate.effective_to IS NULL OR rate.effective_to > $4) + AND NOT EXISTS ( + SELECT 1 + FROM geofence_rates AS historical + WHERE historical.geofence_id = cs.geofence_id + AND historical.effective_from <= cs.started_at + AND (historical.effective_to IS NULL OR historical.effective_to > cs.started_at) + ) + AND ( + (cs.cost_source IS NULL AND cs.cost_decimal IS NULL) + OR (cs.cost_source = 'unknown' AND cs.cost_decimal IS NULL) + OR ( + cs.cost_source = 'default_estimate' + AND (cs.rate_id IS NULL OR cs.rate_id = rate.id) + ) + )` + tag, err := r.pool.Exec(ctx, query, sessionID, geofenceID, rateID, at.UTC()) + if err != nil { + return false, fmt.Errorf("geofence current-rate legacy estimate: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +// validCoordinate reports whether (lat, lon) is usable for geofence +// discovery: finite, in-range, and not the (0,0) null-island sentinel that +// signals "no GPS fix yet" on most trackers. Coordinate freshness is a caller +// concern: the telemetry tracker verifies the timestamped L1 value before it +// invokes discovery and never passes an unverified forward-folded location. +func validCoordinate(lat, lon float64) bool { + if math.IsNaN(lat) || math.IsNaN(lon) || math.IsInf(lat, 0) || math.IsInf(lon, 0) { + return false + } + if lat == 0 && lon == 0 { + return false + } + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 +} + +// FindOrCreateForCharging resolves the charging-place geofence for a +// confirmed charging session at (lat, lon). It first matches an existing +// active geofence containing the point (of either origin — a +// manually-created "Home" geofence is matched just as readily as a +// previously auto-discovered one); if none matches, it idempotently creates +// one provisional 75m-radius circle with: +// - Origin = charging_discovery +// - NeedsReview = true +// - Enabled = false, AlertOnEntry = false, AlertOnExit = false (safe +// defaults — no surprise entry/exit notifications until a human +// reviews/configures the place) +// - Name = suggestedName (already reverse-geocoded by the caller) or a +// neutral fallback when empty/unavailable +// +// Returns an error without creating anything when the coordinates are +// missing, zero, or out of range — this method NEVER creates a (0,0) or +// out-of-range geofence. +// +// Concurrency / dedup: the whole match-or-create sequence runs inside one +// DB transaction holding a single global advisory lock +// (geofenceDiscoveryLockKey), and the match check is repeated INSIDE the +// lock/tx immediately before any insert. This makes the method safe to call +// concurrently and repeatedly (e.g. on retry after a transient failure) +// without ever creating more than one place per physical location: any +// caller that loses the race to acquire the lock will, upon acquiring it +// afterward, find the winner's newly-committed geofence via the re-check +// and return it instead of creating a duplicate. +// +// Returns (geofence, created, error) where created reports whether a new +// provisional geofence was inserted (false when an existing place already +// matched). +func (r *GeofenceRepo) FindOrCreateForCharging(ctx context.Context, lat, lon float64, suggestedName string) (*systemmodel.Geofence, bool, error) { + if !validCoordinate(lat, lon) { + return nil, false, fmt.Errorf("geofence discovery: invalid, missing, or (0,0) coordinates (%v, %v)", lat, lon) + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, false, fmt.Errorf("geofence discovery begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, geofenceDiscoveryLockKey); err != nil { + return nil, false, fmt.Errorf("geofence discovery lock: %w", err) + } + + // Re-check for a match INSIDE the lock — a concurrent caller may have + // just committed a new geofence for this exact neighborhood while we + // were waiting for the lock. + existing, err := findActiveGeofencesNear(ctx, tx, lat, lon) + if err != nil { + return nil, false, fmt.Errorf("geofence discovery match: %w", err) + } + if len(existing) > 0 { + if err := tx.Commit(ctx); err != nil { + return nil, false, fmt.Errorf("geofence discovery commit (matched): %w", err) + } + return existing[0], false, nil + } + + name := strings.TrimSpace(suggestedName) + if name == "" { + name = "Unnamed Charging Place" + } + g := &systemmodel.Geofence{ + Name: name, + PolygonWKT: systemmodel.CircleToPolygonWKT(lat, lon, DiscoveryRadiusMeters), + Enabled: false, + AlertOnEntry: false, + AlertOnExit: false, + Origin: systemmodel.GeofenceOriginChargingDiscovery, + NeedsReview: true, + } + now := time.Now().UTC() + const insertSQL = ` +INSERT INTO geofences (name, polygon_wkt, category, enabled, alert_on_entry, alert_on_exit, created_at, updated_at, origin, needs_review) +VALUES ($1, $2, NULL, $3, $4, $5, $6, $6, $7, $8) +RETURNING id` + if err := tx.QueryRow(ctx, insertSQL, + g.Name, g.PolygonWKT, g.Enabled, g.AlertOnEntry, g.AlertOnExit, now, g.Origin, g.NeedsReview, + ).Scan(&g.ID); err != nil { + return nil, false, fmt.Errorf("geofence discovery insert: %w", err) + } + g.CreatedAt, g.UpdatedAt = now, now + + if err := tx.Commit(ctx); err != nil { + return nil, false, fmt.Errorf("geofence discovery commit (created): %w", err) + } + return g, true, nil +} + +// ListNeedsReview returns active geofences awaiting human review — the +// "Needs Setup" queue surfaced by the Charging Places UI. Ordered oldest +// first so the longest-neglected provisional places surface at the top. +func (r *GeofenceRepo) ListNeedsReview(ctx context.Context) ([]*systemmodel.Geofence, error) { + query := `SELECT ` + geofenceColumns + ` FROM geofences WHERE needs_review = true AND archived_at IS NULL ORDER BY created_at ASC` + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("geofences list_needs_review query: %w", err) + } + defer rows.Close() + + var out []*systemmodel.Geofence + for rows.Next() { + g := &systemmodel.Geofence{} + if err := scanGeofence(rows, g); err != nil { + return nil, fmt.Errorf("geofences list_needs_review scan: %w", err) + } + out = append(out, g) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofences list_needs_review iter: %w", err) + } + return out, nil +} + +// MarkReviewed clears the NeedsReview flag for a geofence — called once a +// human has confirmed/edited an auto-discovered place's name, type, or +// location. It intentionally does not touch any other column (in particular +// never Origin, which stays "charging_discovery" forever as a provenance +// record even after review). +func (r *GeofenceRepo) MarkReviewed(ctx context.Context, id int64) error { + tag, err := r.pool.Exec(ctx, `UPDATE geofences SET needs_review = false, updated_at = now() WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("geofences mark_reviewed %d: %w", id, err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("geofences mark_reviewed %d: %w", id, ErrGeofenceNotFound) + } + return nil +} diff --git a/internal/database/geofence/repo_discovery_test.go b/internal/database/geofence/repo_discovery_test.go new file mode 100644 index 0000000000..8d7a2299d5 --- /dev/null +++ b/internal/database/geofence/repo_discovery_test.go @@ -0,0 +1,387 @@ +package geofence + +import ( + "context" + "errors" + "math" + "strings" + "testing" + "time" + + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------------- +// Legacy charging-place startup backfill. +// --------------------------------------------------------------------------- + +func TestListChargingPlaceBackfillCandidates(t *testing.T) { + name := "Office Garage" + startedAt := time.Date(2025, 8, 1, 9, 0, 0, 0, time.UTC) + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows([][]any{{ + int64(41), int64(7), startedAt, 37.7749, -122.4194, &name, + }})}}} + + got, err := newRepo(pool).ListChargingPlaceBackfillCandidates(context.Background(), 30, 25) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d candidates, want 1", len(got)) + } + if got[0].SessionID != 41 || got[0].VehicleID != 7 || + got[0].StartPlace == nil || *got[0].StartPlace != name { + t.Fatalf("unexpected candidate: %+v", got[0]) + } + call := pool.queryCalls[0] + for _, sub := range []string{ + "geofence_id IS NULL", + "ended_at IS NOT NULL", + "start_lat IS NOT NULL", + "start_lng IS NOT NULL", + "NOT (start_lat = 0 AND start_lng = 0)", + "ORDER BY id", + } { + if !strings.Contains(call.sql, sub) { + t.Errorf("candidate SQL missing %q:\n%s", sub, call.sql) + } + } + if call.args[0] != int64(30) || call.args[1] != 25 { + t.Errorf("candidate args = %v, want [30 25]", call.args) + } +} + +func TestListChargingPlaceBackfillCandidates_QueryError(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{err: errBoom}}} + _, err := newRepo(pool).ListChargingPlaceBackfillCandidates(context.Background(), 0, 100) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } +} + +func TestApplyCurrentRateEstimate(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + + t.Run("applies only an active current fallback", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{tag: tag(1)}}} + applied, err := newRepo(pool).ApplyCurrentRateEstimate(context.Background(), 41, 5, 9, now) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !applied { + t.Fatal("want applied=true") + } + call := pool.execCalls[0] + for _, sub := range []string{ + "cost_source = 'default_estimate'", + "rate.effective_from <= $4", + "historical.effective_from <= cs.started_at", + "cs.cost_source IS NULL AND cs.cost_decimal IS NULL", + "cs.rate_id IS NULL OR cs.rate_id = rate.id", + } { + if !strings.Contains(call.sql, sub) { + t.Errorf("estimate SQL missing %q:\n%s", sub, call.sql) + } + } + if call.args[0] != int64(41) || call.args[1] != int64(5) || + call.args[2] != int64(9) || call.args[3] != now { + t.Errorf("estimate args = %v", call.args) + } + }) + + t.Run("protected or already covered session is skipped", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{tag: tag(0)}}} + applied, err := newRepo(pool).ApplyCurrentRateEstimate(context.Background(), 41, 5, 9, now) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if applied { + t.Fatal("want applied=false") + } + }) + + t.Run("database error is wrapped", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{err: errBoom}}} + _, err := newRepo(pool).ApplyCurrentRateEstimate(context.Background(), 41, 5, 9, now) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// validCoordinate — never create a (0,0) or out-of-range/NaN/Inf geofence. +// --------------------------------------------------------------------------- + +func TestValidCoordinate(t *testing.T) { + tests := []struct { + name string + lat, lon float64 + want bool + }{ + {name: "typical valid point", lat: 37.7749, lon: -122.4194, want: true}, + {name: "boundary north pole", lat: 90, lon: 0, want: true}, + {name: "boundary south pole", lat: -90, lon: 0, want: true}, + {name: "boundary antimeridian east", lat: 0, lon: 180, want: true}, + {name: "boundary antimeridian west", lat: 0, lon: -180, want: true}, + {name: "null island (0,0) rejected", lat: 0, lon: 0, want: false}, + {name: "lat out of range high", lat: 90.0001, lon: 0, want: false}, + {name: "lat out of range low", lat: -90.0001, lon: 0, want: false}, + {name: "lon out of range high", lat: 0, lon: 180.0001, want: false}, + {name: "lon out of range low", lat: 0, lon: -180.0001, want: false}, + {name: "NaN lat", lat: math.NaN(), lon: 0, want: false}, + {name: "NaN lon", lat: 0, lon: math.NaN(), want: false}, + {name: "Inf lat", lat: math.Inf(1), lon: 0, want: false}, + {name: "Inf lon", lat: 0, lon: math.Inf(-1), want: false}, + {name: "zero lat non-zero lon is valid", lat: 0, lon: 45, want: true}, + {name: "zero lon non-zero lat is valid", lat: 45, lon: 0, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := validCoordinate(tc.lat, tc.lon); got != tc.want { + t.Fatalf("validCoordinate(%v, %v) = %v, want %v", tc.lat, tc.lon, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// FindOrCreateForCharging +// --------------------------------------------------------------------------- + +func TestFindOrCreateForCharging_InvalidCoordinatesNeverOpensTransaction(t *testing.T) { + pool := &fakePool{} + _, created, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 0, 0, "Somewhere") + if err == nil { + t.Fatal("want error for (0,0) coordinates") + } + if created { + t.Fatal("must not report created=true on invalid coordinates") + } + if pool.beginCalls != 0 { + t.Fatalf("invalid coordinates must never open a transaction, got %d Begin calls", pool.beginCalls) + } +} + +func TestFindOrCreateForCharging_MatchesExistingInsideLock(t *testing.T) { + existing := &systemmodel.Geofence{ID: 7, Name: "Home", PolygonWKT: squareWKT(37.7749, -122.4194), Enabled: true, CreatedAt: fixedTime, UpdatedAt: fixedTime} + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, // pg_advisory_xact_lock + queryQueue: []queryResult{{rows: newFakeRows([][]any{geofenceRowVals(existing)})}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + + g, created, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, "New Suggested Name") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if created { + t.Fatal("want created=false when an existing geofence matches") + } + if g == nil || g.ID != 7 { + t.Fatalf("want the matched geofence (id=7), got %+v", g) + } + if tx.commitCalls != 1 { + t.Fatalf("want 1 commit, got %d", tx.commitCalls) + } + // Must never fall through to the INSERT path when a match is found. + if len(tx.queryRowCalls) != 0 { + t.Fatalf("must not INSERT when a match exists, got %d QueryRow calls", len(tx.queryRowCalls)) + } + lockCall := tx.execCalls[0] + if !strings.Contains(lockCall.sql, "pg_advisory_xact_lock") { + t.Errorf("first Exec must acquire the advisory lock: %s", lockCall.sql) + } +} + +func TestFindOrCreateForCharging_CreatesProvisionalWhenNoMatch(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, // advisory lock + queryQueue: []queryResult{{rows: newFakeRows(nil)}}, // no match + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(55)}}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + + g, created, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, "Joe's Diner") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !created { + t.Fatal("want created=true when nothing matches") + } + if g == nil || g.ID != 55 { + t.Fatalf("want new geofence id=55, got %+v", g) + } + if g.Name != "Joe's Diner" { + t.Errorf("name: want suggested name, got %q", g.Name) + } + if g.Origin != systemmodel.GeofenceOriginChargingDiscovery { + t.Errorf("origin: want charging_discovery, got %q", g.Origin) + } + if !g.NeedsReview { + t.Error("want NeedsReview=true for a freshly discovered place") + } + if g.Enabled || g.AlertOnEntry || g.AlertOnExit { + t.Errorf("want safe disabled defaults for a provisional place, got %+v", g) + } + insertCall := tx.queryRowCalls[0] + for _, sub := range []string{"INSERT INTO geofences", "RETURNING id"} { + if !strings.Contains(insertCall.sql, sub) { + t.Errorf("insert SQL missing %q:\n%s", sub, insertCall.sql) + } + } + if tx.commitCalls != 1 { + t.Fatalf("want 1 commit, got %d", tx.commitCalls) + } +} + +func TestFindOrCreateForCharging_EmptySuggestedNameFallsBackToNeutralName(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, + queryQueue: []queryResult{{rows: newFakeRows(nil)}}, + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(1)}}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + + g, _, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, " ") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if g.Name == "" || strings.TrimSpace(g.Name) == "" { + t.Fatalf("want a non-blank neutral fallback name, got %q", g.Name) + } +} + +func TestFindOrCreateForCharging_BeginError(t *testing.T) { + pool := &fakePool{beginQueue: []beginResult{{err: errBoom}}} + _, _, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, "x") + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } +} + +func TestFindOrCreateForCharging_LockError(t *testing.T) { + tx := &fakeTx{execQueue: []execResult{{err: errBoom}}} + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + _, _, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, "x") + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } +} + +func TestFindOrCreateForCharging_MatchQueryError(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, + queryQueue: []queryResult{{err: errBoom}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + _, _, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, "x") + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } +} + +func TestFindOrCreateForCharging_InsertError(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, + queryQueue: []queryResult{{rows: newFakeRows(nil)}}, + queryRowQueue: []pgx.Row{fakeRow{scanErr: errBoom}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + _, _, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, "x") + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } +} + +func TestFindOrCreateForCharging_CommitError(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, + queryQueue: []queryResult{{rows: newFakeRows(nil)}}, + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(1)}}}, + commitErr: errBoom, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + _, _, err := newRepo(pool).FindOrCreateForCharging(context.Background(), 37.7749, -122.4194, "x") + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } +} + +// TestFindOrCreateForCharging_DiscoveryRadiusMatchesBusinessRule pins the +// documented 75m provisional-circle radius so an accidental future edit is +// caught immediately by a test failure rather than silently shrinking or +// growing every auto-discovered place's coverage. +func TestFindOrCreateForCharging_DiscoveryRadiusMatchesBusinessRule(t *testing.T) { + if DiscoveryRadiusMeters != 75.0 { + t.Fatalf("DiscoveryRadiusMeters=%v, want 75.0 per business rule", DiscoveryRadiusMeters) + } +} + +// --------------------------------------------------------------------------- +// ListNeedsReview +// --------------------------------------------------------------------------- + +func TestListNeedsReview(t *testing.T) { + pending := &systemmodel.Geofence{ID: 1, Name: "Unnamed Charging Place", PolygonWKT: squareWKT(1, 1), Origin: systemmodel.GeofenceOriginChargingDiscovery, NeedsReview: true, CreatedAt: fixedTime, UpdatedAt: fixedTime} + + t.Run("returns pending rows", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows([][]any{geofenceRowVals(pending)})}}} + got, err := newRepo(pool).ListNeedsReview(context.Background()) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 || got[0].ID != 1 { + t.Fatalf("unexpected result: %+v", got) + } + call := pool.queryCalls[0] + for _, sub := range []string{"needs_review = true", "archived_at IS NULL", "ORDER BY created_at ASC"} { + if !strings.Contains(call.sql, sub) { + t.Errorf("SQL missing %q:\n%s", sub, call.sql) + } + } + }) + + t.Run("query error wrapped", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{err: errBoom}}} + _, err := newRepo(pool).ListNeedsReview(context.Background()) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// MarkReviewed +// --------------------------------------------------------------------------- + +func TestMarkReviewed(t *testing.T) { + t.Run("success", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{tag: tag(1)}}} + if err := newRepo(pool).MarkReviewed(context.Background(), 1); err != nil { + t.Fatalf("unexpected err: %v", err) + } + call := pool.execCalls[0] + if !strings.Contains(call.sql, "needs_review = false") { + t.Errorf("unexpected SQL: %s", call.sql) + } + }) + + t.Run("not found", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{tag: tag(0)}}} + err := newRepo(pool).MarkReviewed(context.Background(), 999) + if !errors.Is(err, ErrGeofenceNotFound) { + t.Fatalf("err=%v, want ErrGeofenceNotFound", err) + } + }) + + t.Run("exec error wrapped", func(t *testing.T) { + pool := &fakePool{execQueue: []execResult{{err: errBoom}}} + err := newRepo(pool).MarkReviewed(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} diff --git a/internal/database/geofence/repo_rates.go b/internal/database/geofence/repo_rates.go new file mode 100644 index 0000000000..a2be349d93 --- /dev/null +++ b/internal/database/geofence/repo_rates.go @@ -0,0 +1,406 @@ +package geofence + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// ============================================================================= +// repo_rates.go — time-versioned electricity-rate CRUD for the +// charging-place pricing feature (migration +// 000228_geofence_charging_place_pricing). +// +// geofence_rates is the ONE normalized, effective-dated source of truth for a +// place's rate history: there is deliberately no separate mutable +// "current rate" column on geofences. "The current rate" is simply whichever +// row's half-open [effective_from, effective_to) interval contains the +// query instant. Non-overlap per geofence is enforced by the +// database (a GIST exclusion constraint), not just application code, so a +// concurrent double-write can never silently corrupt history. +// ============================================================================= + +// ErrRateConflict is returned when creating/replacing a rate would overlap +// an existing interval for the same geofence — mapped from the underlying +// PostgreSQL exclusion-constraint violation (SQLSTATE 23P01). +var ErrRateConflict = errors.New("geofence rate interval conflicts with an existing rate") + +// ErrRateNotFound is returned when a rate id does not exist for the given +// (or any) geofence. +var ErrRateNotFound = errors.New("geofence rate not found") + +// ErrRateInUse is returned when a rate is already referenced by one or more +// charging sessions. Historical tariff provenance is immutable once used. +var ErrRateInUse = errors.New("geofence rate is referenced by charging sessions") + +// ErrRateImmutable is returned when deleting a rate that has already become +// effective. Only unused future schedules may be cancelled. +var ErrRateImmutable = errors.New("effective geofence rates are immutable") + +// ErrGeofenceNotFound is returned by mutation-only methods (MarkReviewed, +// Archive, Unarchive) that have nothing else to return when the targeted +// geofence id does not exist. +var ErrGeofenceNotFound = errors.New("geofence not found") + +// classifyRateConflict maps a PostgreSQL exclusion/unique-violation error +// into ErrRateConflict, mirroring the classifyPGError pattern used by +// internal/database/fleetops for the same class of problem. +func classifyRateConflict(err error) error { + if err == nil { + return nil + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23P01", "23505": + return fmt.Errorf("%w: %v", ErrRateConflict, err) + } + } + return err +} + +const geofenceRateColumns = `id, geofence_id, rate_per_wh, currency, effective_from, effective_to, created_at` + +func scanGeofenceRate(row pgx.Row) (*systemmodel.GeofenceRate, error) { + gr := &systemmodel.GeofenceRate{} + err := row.Scan(&gr.ID, &gr.GeofenceID, &gr.RatePerWh, &gr.Currency, &gr.EffectiveFrom, &gr.EffectiveTo, &gr.CreatedAt) + if err != nil { + return nil, err + } + return gr, nil +} + +// CreateRate inserts a new rate version for a geofence. +// +// If the geofence currently has an open interval (effective_to IS NULL), and +// the new rate is also open-ended, the old interval is auto-closed +// (effective_to := new.EffectiveFrom) in the same transaction — this is the +// primary "add a new version, the old one silently ends where the new one +// begins" business flow (e.g. $0.10/kWh before 2026-08-27, $0.12/kWh from +// then on) and produces adjacent, non-overlapping half-open intervals so the +// exclusion constraint never trips for this common case. +// +// A bounded interval never auto-closes an existing open rate. If it overlaps +// one, the database rejects it rather than silently truncating the current +// rate and leaving a pricing gap after the bounded interval ends. +// +// Any other overlap (e.g. a backdated insert into the middle of history) is +// rejected by the database's GIST exclusion constraint and surfaced as +// ErrRateConflict — arbitrary +// insert-in-the-middle-and-cascade-shift is intentionally not supported. +func (r *GeofenceRepo) CreateRate(ctx context.Context, gr *systemmodel.GeofenceRate) error { + if err := validateGeofenceRate(gr); err != nil { + return err + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("geofence rates create begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if gr.EffectiveTo == nil { + const closeOpenSQL = ` +UPDATE geofence_rates + SET effective_to = $2 + WHERE geofence_id = $1 + AND effective_to IS NULL + AND effective_from < $2` + if _, err := tx.Exec(ctx, closeOpenSQL, gr.GeofenceID, gr.EffectiveFrom); err != nil { + return fmt.Errorf("geofence rates create close-open: %w", err) + } + } + + const insertSQL = ` +INSERT INTO geofence_rates (geofence_id, rate_per_wh, currency, effective_from, effective_to) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, created_at` + if err := tx.QueryRow(ctx, insertSQL, gr.GeofenceID, gr.RatePerWh, gr.Currency, gr.EffectiveFrom, gr.EffectiveTo). + Scan(&gr.ID, &gr.CreatedAt); err != nil { + return classifyRateConflict(fmt.Errorf("geofence rates create insert: %w", err)) + } + + now := time.Now().UTC() + if gr.IsActiveAt(now) { + if err := applyCreatedCurrentRateToSessions(ctx, tx, gr); err != nil { + return err + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("geofence rates create commit: %w", err) + } + return nil +} + +// applyCreatedCurrentRateToSessions applies a newly-created rate to eligible, +// completed sessions already attributed to the place. Sessions inside the +// rate's interval receive authoritative geofence_tariff provenance. Older +// sessions not covered by any configured interval receive today's rate as a +// default_estimate, preserving the ability to replace it later with explicit +// historical pricing. Both updates share CreateRate's transaction. +func applyCreatedCurrentRateToSessions(ctx context.Context, tx pgx.Tx, gr *systemmodel.GeofenceRate) error { + const applyExact = ` +UPDATE charging_sessions + SET cost_decimal = ROUND(total_energy_added_wh::numeric * $3::numeric, 6), + cost_currency = $4, + rate_id = $2, + cost_source = 'geofence_tariff' + WHERE geofence_id = $1 + AND ended_at IS NOT NULL + AND total_energy_added_wh IS NOT NULL + AND started_at >= $5 + AND ($6::timestamptz IS NULL OR started_at < $6) + AND ( + cost_source = 'default_estimate' + OR (cost_source IS NULL AND cost_decimal IS NULL) + OR (cost_source = 'unknown' AND cost_decimal IS NULL) + OR (cost_source = 'geofence_tariff' AND rate_id = $2) + )` + if _, err := tx.Exec( + ctx, + applyExact, + gr.GeofenceID, + gr.ID, + gr.RatePerWh, + gr.Currency, + gr.EffectiveFrom, + gr.EffectiveTo, + ); err != nil { + return fmt.Errorf("geofence rates create apply exact sessions: %w", err) + } + + const applyUncovered = ` +UPDATE charging_sessions AS cs + SET cost_decimal = ROUND(cs.total_energy_added_wh::numeric * $3::numeric, 6), + cost_currency = $4, + rate_id = $2, + cost_source = 'default_estimate' + WHERE cs.geofence_id = $1 + AND cs.ended_at IS NOT NULL + AND cs.total_energy_added_wh IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM geofence_rates AS historical + WHERE historical.geofence_id = cs.geofence_id + AND historical.effective_from <= cs.started_at + AND (historical.effective_to IS NULL OR historical.effective_to > cs.started_at) + ) + AND ( + (cs.cost_source IS NULL AND cs.cost_decimal IS NULL) + OR (cs.cost_source = 'unknown' AND cs.cost_decimal IS NULL) + OR ( + cs.cost_source = 'default_estimate' + AND (cs.rate_id IS NULL OR cs.rate_id = $2) + ) + )` + if _, err := tx.Exec(ctx, applyUncovered, gr.GeofenceID, gr.ID, gr.RatePerWh, gr.Currency); err != nil { + return fmt.Errorf("geofence rates create apply current estimate: %w", err) + } + return nil +} + +// validateGeofenceRate applies the non-negotiable rate-integrity checks +// (non-negative finite rate, ISO-4217-shaped currency, well-formed interval) +// at the repository boundary so every write path — handler-driven create, +// discovery enrichment, future import — gets the same guarantees regardless +// of caller. +func validateGeofenceRate(gr *systemmodel.GeofenceRate) error { + if gr == nil { + return fmt.Errorf("geofence rate: nil") + } + if gr.GeofenceID <= 0 { + return fmt.Errorf("geofence rate: geofence_id required") + } + if math.IsNaN(gr.RatePerWh) || math.IsInf(gr.RatePerWh, 0) || + gr.RatePerWh < 0 || gr.RatePerWh >= 1_000_000 { + return fmt.Errorf("geofence rate: rate_per_wh must be finite and between 0 (inclusive) and 1000000 (exclusive)") + } + if len(gr.Currency) != 3 { + return fmt.Errorf("geofence rate: currency must be a 3-letter ISO 4217 code") + } + for _, c := range gr.Currency { + if c < 'A' || c > 'Z' { + return fmt.Errorf("geofence rate: currency must be uppercase ISO 4217 (got %q)", gr.Currency) + } + } + if gr.EffectiveFrom.IsZero() { + return fmt.Errorf("geofence rate: effective_from required") + } + if gr.EffectiveTo != nil && !gr.EffectiveTo.After(gr.EffectiveFrom) { + return fmt.Errorf("geofence rate: effective_to must be after effective_from") + } + return nil +} + +// ListRates returns every rate version for a geofence, newest +// effective_from first — the shape the rate-history UI panel renders +// directly. +func (r *GeofenceRepo) ListRates(ctx context.Context, geofenceID int64) ([]*systemmodel.GeofenceRate, error) { + query := `SELECT ` + geofenceRateColumns + ` FROM geofence_rates WHERE geofence_id=$1 ORDER BY effective_from DESC` + rows, err := r.pool.Query(ctx, query, geofenceID) + if err != nil { + return nil, fmt.Errorf("geofence rates list query: %w", err) + } + defer rows.Close() + + var out []*systemmodel.GeofenceRate + for rows.Next() { + gr, err := scanGeofenceRate(rows) + if err != nil { + return nil, fmt.Errorf("geofence rates list scan: %w", err) + } + out = append(out, gr) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofence rates list iter: %w", err) + } + return out, nil +} + +// GetRateByID fetches a single rate version, scoped to a geofence so a +// caller can never accidentally address another place's rate by guessing an +// id. Returns (nil, nil) when not found. +func (r *GeofenceRepo) GetRateByID(ctx context.Context, geofenceID, rateID int64) (*systemmodel.GeofenceRate, error) { + query := `SELECT ` + geofenceRateColumns + ` FROM geofence_rates WHERE id=$1 AND geofence_id=$2` + gr, err := scanGeofenceRate(r.pool.QueryRow(ctx, query, rateID, geofenceID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("geofence rates get %d: %w", rateID, err) + } + return gr, nil +} + +// GetActiveRateAt returns the rate version whose half-open interval +// contains instant `at` for the given geofence, or (nil, nil) when no rate +// has been configured for that instant. Normal pricing resolves with the +// charging session's started_at so historical rates remain stable. The +// legacy startup backfill performs a second lookup at "now" only when the +// first lookup finds no historical interval, and records that fallback as +// default_estimate rather than authoritative geofence_tariff provenance. +func (r *GeofenceRepo) GetActiveRateAt(ctx context.Context, geofenceID int64, at time.Time) (*systemmodel.GeofenceRate, error) { + query := `SELECT ` + geofenceRateColumns + ` FROM geofence_rates +WHERE geofence_id=$1 AND effective_from <= $2 AND (effective_to IS NULL OR effective_to > $2) +ORDER BY effective_from DESC +LIMIT 1` + gr, err := scanGeofenceRate(r.pool.QueryRow(ctx, query, geofenceID, at)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("geofence rates get_active_at: %w", err) + } + return gr, nil +} + +// ListActiveRatesNow returns the currently-active rate for every geofence +// that has one configured — i.e. the row whose half-open interval contains +// this instant, which (thanks to the no-overlap exclusion constraint) is +// never more than one row per geofence, and is NOT always the row with +// effective_to IS NULL (a geofence with a future-dated rate already queued +// has its "now-active" row closed at that future effective_from). This +// powers the Charging Places list/summary view's "current rate" column in +// one query instead of one round trip per place. +func (r *GeofenceRepo) ListActiveRatesNow(ctx context.Context) ([]*systemmodel.GeofenceRate, error) { + query := `SELECT ` + geofenceRateColumns + ` FROM geofence_rates +WHERE effective_from <= now() AND (effective_to IS NULL OR effective_to > now()) +ORDER BY geofence_id` + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("geofence rates list_active_now query: %w", err) + } + defer rows.Close() + + var out []*systemmodel.GeofenceRate + for rows.Next() { + gr, err := scanGeofenceRate(rows) + if err != nil { + return nil, fmt.Errorf("geofence rates list_active_now scan: %w", err) + } + out = append(out, gr) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("geofence rates list_active_now iter: %w", err) + } + return out, nil +} + +// DeleteRate cancels an unused future rate schedule. Once a rate has become +// effective or is referenced by any charging session it is immutable. +// +// Cancelling a future rate also extends its immediately preceding adjacent +// interval through the cancelled interval, so removing a scheduled change +// cannot leave a silent pricing gap. +func (r *GeofenceRepo) DeleteRate(ctx context.Context, geofenceID, rateID int64) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("geofence rates delete begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + const loadSQL = ` +SELECT ` + geofenceRateColumns + `, effective_from > now() +FROM geofence_rates +WHERE id=$1 AND geofence_id=$2 +FOR UPDATE` + rate := &systemmodel.GeofenceRate{} + var future bool + err = tx.QueryRow(ctx, loadSQL, rateID, geofenceID).Scan( + &rate.ID, &rate.GeofenceID, &rate.RatePerWh, &rate.Currency, + &rate.EffectiveFrom, &rate.EffectiveTo, &rate.CreatedAt, &future, + ) + if errors.Is(err, pgx.ErrNoRows) { + return ErrRateNotFound + } + if err != nil { + return fmt.Errorf("geofence rates delete load %d: %w", rateID, err) + } + if !future { + return ErrRateImmutable + } + + var inUse bool + if err := tx.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM charging_sessions WHERE rate_id=$1)`, + rateID, + ).Scan(&inUse); err != nil { + return fmt.Errorf("geofence rates delete usage check %d: %w", rateID, err) + } + if inUse { + return ErrRateInUse + } + + if _, err := tx.Exec(ctx, + `DELETE FROM geofence_rates WHERE id=$1 AND geofence_id=$2`, + rateID, geofenceID, + ); err != nil { + return fmt.Errorf("geofence rates delete %d: %w", rateID, err) + } + + // Restore continuity by extending the adjacent predecessor through the + // cancelled interval. If no predecessor exists, the place simply remains + // unpriced until the next configured interval. + if _, err := tx.Exec(ctx, ` +UPDATE geofence_rates + SET effective_to = $3 + WHERE geofence_id = $1 + AND effective_to = $2`, + geofenceID, rate.EffectiveFrom, rate.EffectiveTo, + ); err != nil { + return fmt.Errorf("geofence rates delete restore predecessor %d: %w", rateID, err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("geofence rates delete commit: %w", err) + } + return nil +} diff --git a/internal/database/geofence/repo_rates_test.go b/internal/database/geofence/repo_rates_test.go new file mode 100644 index 0000000000..47a1115559 --- /dev/null +++ b/internal/database/geofence/repo_rates_test.go @@ -0,0 +1,606 @@ +package geofence + +import ( + "context" + "errors" + "math" + "strings" + "testing" + "time" + + systemmodel "github.com/ev-dev-labs/teslasync/internal/models/system" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// --------------------------------------------------------------------------- +// validateGeofenceRate +// --------------------------------------------------------------------------- + +func TestValidateGeofenceRate(t *testing.T) { + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC) + + valid := func() *systemmodel.GeofenceRate { + return &systemmodel.GeofenceRate{GeofenceID: 1, RatePerWh: 0.0001005, Currency: "USD", EffectiveFrom: from} + } + + tests := []struct { + name string + mutate func(*systemmodel.GeofenceRate) + nilRate bool + wantErr string + }{ + {name: "valid open interval", mutate: func(gr *systemmodel.GeofenceRate) {}}, + {name: "valid closed interval", mutate: func(gr *systemmodel.GeofenceRate) { gr.EffectiveTo = &to }}, + {name: "valid zero rate", mutate: func(gr *systemmodel.GeofenceRate) { gr.RatePerWh = 0 }}, + {name: "rate exceeds database precision bound", mutate: func(gr *systemmodel.GeofenceRate) { gr.RatePerWh = 1_000_000 }, wantErr: "1000000"}, + {name: "nil rate", nilRate: true, wantErr: "nil"}, + {name: "geofence id zero", mutate: func(gr *systemmodel.GeofenceRate) { gr.GeofenceID = 0 }, wantErr: "geofence_id required"}, + {name: "geofence id negative", mutate: func(gr *systemmodel.GeofenceRate) { gr.GeofenceID = -1 }, wantErr: "geofence_id required"}, + {name: "NaN rate", mutate: func(gr *systemmodel.GeofenceRate) { gr.RatePerWh = math.NaN() }, wantErr: "must be finite"}, + {name: "Inf rate", mutate: func(gr *systemmodel.GeofenceRate) { gr.RatePerWh = math.Inf(1) }, wantErr: "must be finite"}, + {name: "negative rate", mutate: func(gr *systemmodel.GeofenceRate) { gr.RatePerWh = -0.0001 }, wantErr: "must be finite"}, + {name: "currency too short", mutate: func(gr *systemmodel.GeofenceRate) { gr.Currency = "US" }, wantErr: "3-letter ISO 4217"}, + {name: "currency too long", mutate: func(gr *systemmodel.GeofenceRate) { gr.Currency = "USDD" }, wantErr: "3-letter ISO 4217"}, + {name: "currency lowercase", mutate: func(gr *systemmodel.GeofenceRate) { gr.Currency = "usd" }, wantErr: "uppercase ISO 4217"}, + {name: "currency mixed case", mutate: func(gr *systemmodel.GeofenceRate) { gr.Currency = "Usd" }, wantErr: "uppercase ISO 4217"}, + {name: "currency with digit", mutate: func(gr *systemmodel.GeofenceRate) { gr.Currency = "US1" }, wantErr: "uppercase ISO 4217"}, + {name: "effective_from zero", mutate: func(gr *systemmodel.GeofenceRate) { gr.EffectiveFrom = time.Time{} }, wantErr: "effective_from required"}, + {name: "effective_to equal effective_from", mutate: func(gr *systemmodel.GeofenceRate) { t := from; gr.EffectiveTo = &t }, wantErr: "effective_to must be after"}, + {name: "effective_to before effective_from", mutate: func(gr *systemmodel.GeofenceRate) { t := from.Add(-time.Hour); gr.EffectiveTo = &t }, wantErr: "effective_to must be after"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var gr *systemmodel.GeofenceRate + if !tc.nilRate { + gr = valid() + tc.mutate(gr) + } + err := validateGeofenceRate(gr) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error=%v, want substring %q", err, tc.wantErr) + } + }) + } +} + +// --------------------------------------------------------------------------- +// classifyRateConflict +// --------------------------------------------------------------------------- + +func TestClassifyRateConflict(t *testing.T) { + t.Run("nil passthrough", func(t *testing.T) { + if err := classifyRateConflict(nil); err != nil { + t.Fatalf("want nil, got %v", err) + } + }) + t.Run("exclusion violation 23P01 maps to ErrRateConflict", func(t *testing.T) { + err := classifyRateConflict(&pgconn.PgError{Code: "23P01", Message: "conflicting key value"}) + if !errors.Is(err, ErrRateConflict) { + t.Fatalf("err=%v, want wrapped ErrRateConflict", err) + } + }) + t.Run("unique violation 23505 maps to ErrRateConflict", func(t *testing.T) { + err := classifyRateConflict(&pgconn.PgError{Code: "23505", Message: "duplicate key value"}) + if !errors.Is(err, ErrRateConflict) { + t.Fatalf("err=%v, want wrapped ErrRateConflict", err) + } + }) + t.Run("unrelated pg error passes through unchanged", func(t *testing.T) { + orig := &pgconn.PgError{Code: "42P01", Message: "relation does not exist"} + err := classifyRateConflict(orig) + if !errors.Is(err, orig) || errors.Is(err, ErrRateConflict) { + t.Fatalf("err=%v, want unchanged passthrough (not ErrRateConflict)", err) + } + }) + t.Run("non-pg error passes through unchanged", func(t *testing.T) { + if err := classifyRateConflict(errBoom); !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want errBoom passthrough", err) + } + }) +} + +// --------------------------------------------------------------------------- +// CreateRate +// --------------------------------------------------------------------------- + +func sampleRate() *systemmodel.GeofenceRate { + return &systemmodel.GeofenceRate{ + GeofenceID: 1, + RatePerWh: 0.0001005, + Currency: "USD", + EffectiveFrom: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func TestCreateRate_ValidationRejectedBeforeAnyDBCall(t *testing.T) { + pool := &fakePool{} + gr := sampleRate() + gr.Currency = "us" // invalid + err := newRepo(pool).CreateRate(context.Background(), gr) + if err == nil { + t.Fatal("want validation error") + } + if pool.beginCalls != 0 { + t.Fatalf("validation failure must not open a transaction, got %d Begin calls", pool.beginCalls) + } +} + +func TestCreateRate_Success(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{ + {tag: tag(0)}, // close-open UPDATE (no prior open interval) + {tag: tag(2)}, // exact-interval sessions + {tag: tag(3)}, // uncovered legacy estimates + }, + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(99), fixedTime}}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + gr := sampleRate() + + if err := newRepo(pool).CreateRate(context.Background(), gr); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if gr.ID != 99 || !gr.CreatedAt.Equal(fixedTime) { + t.Fatalf("rate not populated from RETURNING: %+v", gr) + } + if tx.commitCalls != 1 { + t.Fatalf("want 1 commit, got %d", tx.commitCalls) + } + if len(tx.execCalls) != 3 { + t.Fatalf("want close-open + exact + uncovered Exec calls, got %d", len(tx.execCalls)) + } + closeCall := tx.execCalls[0] + for _, sub := range []string{"UPDATE geofence_rates", "effective_to IS NULL", "effective_from < $2"} { + if !strings.Contains(closeCall.sql, sub) { + t.Errorf("close-open SQL missing %q:\n%s", sub, closeCall.sql) + } + } + if closeCall.args[0] != gr.GeofenceID || closeCall.args[1] != gr.EffectiveFrom { + t.Errorf("close-open args mismatch: %v", closeCall.args) + } + exactCall := tx.execCalls[1] + for _, sub := range []string{ + "cost_source = 'geofence_tariff'", + "started_at >= $5", + "cost_source = 'default_estimate'", + "cost_source = 'geofence_tariff' AND rate_id = $2", + } { + if !strings.Contains(exactCall.sql, sub) { + t.Errorf("exact-session SQL missing %q:\n%s", sub, exactCall.sql) + } + } + uncoveredCall := tx.execCalls[2] + for _, sub := range []string{ + "cost_source = 'default_estimate'", + "NOT EXISTS", + "historical.effective_from <= cs.started_at", + "cs.rate_id IS NULL OR cs.rate_id = $2", + } { + if !strings.Contains(uncoveredCall.sql, sub) { + t.Errorf("uncovered-session SQL missing %q:\n%s", sub, uncoveredCall.sql) + } + } + insertCall := tx.queryRowCalls[0] + for _, sub := range []string{"INSERT INTO geofence_rates", "RETURNING id, created_at"} { + if !strings.Contains(insertCall.sql, sub) { + t.Errorf("insert SQL missing %q:\n%s", sub, insertCall.sql) + } + } + if insertCall.args[0] != gr.GeofenceID || insertCall.args[1] != gr.RatePerWh || insertCall.args[2] != gr.Currency { + t.Errorf("insert args mismatch: %v", insertCall.args) + } +} + +func TestCreateRate_BoundedIntervalDoesNotTruncateOpenRate(t *testing.T) { + to := sampleRate().EffectiveFrom.Add(24 * time.Hour) + tx := &fakeTx{ + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(100), fixedTime}}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + gr := sampleRate() + gr.EffectiveTo = &to + + if err := newRepo(pool).CreateRate(context.Background(), gr); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(tx.execCalls) != 0 { + t.Fatalf("bounded rate must not truncate an open-ended rate, got %d UPDATE calls", len(tx.execCalls)) + } + if tx.commitCalls != 1 { + t.Fatalf("want 1 commit, got %d", tx.commitCalls) + } +} + +func TestCreateRate_ConflictMapped(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, + queryRowQueue: []pgx.Row{fakeRow{scanErr: &pgconn.PgError{Code: "23P01", Message: "conflicting key value"}}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + + err := newRepo(pool).CreateRate(context.Background(), sampleRate()) + if !errors.Is(err, ErrRateConflict) { + t.Fatalf("err=%v, want wrapped ErrRateConflict", err) + } +} + +func TestCreateRate_BeginError(t *testing.T) { + pool := &fakePool{beginQueue: []beginResult{{err: errBoom}}} + err := newRepo(pool).CreateRate(context.Background(), sampleRate()) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } +} + +func TestCreateRate_CloseOpenExecError(t *testing.T) { + tx := &fakeTx{execQueue: []execResult{{err: errBoom}}} + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).CreateRate(context.Background(), sampleRate()) + if !errors.Is(err, errBoom) || !strings.Contains(err.Error(), "close-open") { + t.Fatalf("err=%v, want wrapped close-open errBoom", err) + } +} + +func TestCreateRate_ExactSessionBackfillErrorRollsBack(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{ + {tag: tag(0)}, + {err: errBoom}, + }, + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(1), fixedTime}}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).CreateRate(context.Background(), sampleRate()) + if !errors.Is(err, errBoom) || !strings.Contains(err.Error(), "apply exact sessions") { + t.Fatalf("err=%v, want wrapped exact-session errBoom", err) + } + if tx.commitCalls != 0 { + t.Fatalf("failed backfill must not commit, got %d commits", tx.commitCalls) + } +} + +func TestCreateRate_CurrentEstimateBackfillErrorRollsBack(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{ + {tag: tag(0)}, + {tag: tag(1)}, + {err: errBoom}, + }, + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(1), fixedTime}}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).CreateRate(context.Background(), sampleRate()) + if !errors.Is(err, errBoom) || !strings.Contains(err.Error(), "apply current estimate") { + t.Fatalf("err=%v, want wrapped current-estimate errBoom", err) + } + if tx.commitCalls != 0 { + t.Fatalf("failed backfill must not commit, got %d commits", tx.commitCalls) + } +} + +func TestCreateRate_CommitError(t *testing.T) { + tx := &fakeTx{ + execQueue: []execResult{{tag: tag(0)}}, + queryRowQueue: []pgx.Row{fakeRow{vals: []any{int64(1), fixedTime}}}, + commitErr: errBoom, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).CreateRate(context.Background(), sampleRate()) + if !errors.Is(err, errBoom) || !strings.Contains(err.Error(), "commit") { + t.Fatalf("err=%v, want wrapped commit errBoom", err) + } +} + +// --------------------------------------------------------------------------- +// ListRates +// --------------------------------------------------------------------------- + +func TestListRates(t *testing.T) { + r1 := &systemmodel.GeofenceRate{ID: 2, GeofenceID: 1, RatePerWh: 0.00012, Currency: "USD", EffectiveFrom: fixedTime, CreatedAt: fixedTime} + r2 := &systemmodel.GeofenceRate{ID: 1, GeofenceID: 1, RatePerWh: 0.0001, Currency: "USD", EffectiveFrom: fixedTime.Add(-24 * time.Hour), CreatedAt: fixedTime} + + t.Run("returns rows newest first as scripted", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows([][]any{geofenceRateRowVals(r1), geofenceRateRowVals(r2)})}}} + got, err := newRepo(pool).ListRates(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 2 || got[0].ID != 2 || got[1].ID != 1 { + t.Fatalf("unexpected rates: %+v", got) + } + call := pool.queryCalls[0] + if !strings.Contains(call.sql, "WHERE geofence_id=$1") || !strings.Contains(call.sql, "ORDER BY effective_from DESC") { + t.Errorf("unexpected SQL: %s", call.sql) + } + if len(call.args) != 1 || call.args[0] != int64(1) { + t.Errorf("args: want [1], got %v", call.args) + } + }) + + t.Run("query error wrapped", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{err: errBoom}}} + _, err := newRepo(pool).ListRates(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) + + t.Run("scan error wrapped", func(t *testing.T) { + rows := newFakeRows([][]any{geofenceRateRowVals(r1)}) + rows.scanErrAt = 0 + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + _, err := newRepo(pool).ListRates(context.Background(), 1) + if err == nil || !strings.Contains(err.Error(), "list scan") { + t.Fatalf("err=%v, want list scan error", err) + } + }) + + t.Run("iteration error wrapped", func(t *testing.T) { + rows := newFakeRows([][]any{geofenceRateRowVals(r1)}) + rows.iterErr = errBoom + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + _, err := newRepo(pool).ListRates(context.Background(), 1) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// GetRateByID +// --------------------------------------------------------------------------- + +func TestGetRateByID(t *testing.T) { + want := &systemmodel.GeofenceRate{ID: 5, GeofenceID: 1, RatePerWh: 0.00012, Currency: "USD", EffectiveFrom: fixedTime, CreatedAt: fixedTime} + + t.Run("found", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRateRowVals(want)}}} + got, err := newRepo(pool).GetRateByID(context.Background(), 1, 5) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got == nil || got.ID != 5 { + t.Fatalf("got=%+v, want id=5", got) + } + call := pool.queryRowCalls[0] + if !strings.Contains(call.sql, "WHERE id=$1 AND geofence_id=$2") { + t.Errorf("unexpected SQL: %s", call.sql) + } + if call.args[0] != int64(5) || call.args[1] != int64(1) { + t.Errorf("args: want [5 1], got %v", call.args) + } + }) + + t.Run("no rows returns nil,nil", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{noRow()}} + got, err := newRepo(pool).GetRateByID(context.Background(), 1, 999) + if err != nil || got != nil { + t.Fatalf("got=%v err=%v, want nil,nil", got, err) + } + }) + + t.Run("scan error wrapped", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{scanErr: errBoom}}} + _, err := newRepo(pool).GetRateByID(context.Background(), 1, 5) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// GetActiveRateAt — exact cutoff-boundary keying (must query by the given +// instant, never "now()", so a session's own started_at always resolves the +// rate that was active when IT started). +// --------------------------------------------------------------------------- + +func TestGetActiveRateAt(t *testing.T) { + at := time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC) + want := &systemmodel.GeofenceRate{ID: 2, GeofenceID: 1, RatePerWh: 0.00012, Currency: "USD", EffectiveFrom: at, CreatedAt: fixedTime} + + t.Run("queries by the supplied instant, not now()", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: geofenceRateRowVals(want)}}} + got, err := newRepo(pool).GetActiveRateAt(context.Background(), 1, at) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got == nil || got.ID != 2 { + t.Fatalf("got=%+v, want id=2", got) + } + call := pool.queryRowCalls[0] + if strings.Contains(strings.ToLower(call.sql), "now()") { + t.Errorf("GetActiveRateAt must key off the passed instant, not now(): %s", call.sql) + } + for _, sub := range []string{"effective_from <= $2", "effective_to IS NULL OR effective_to > $2", "ORDER BY effective_from DESC", "LIMIT 1"} { + if !strings.Contains(call.sql, sub) { + t.Errorf("SQL missing %q:\n%s", sub, call.sql) + } + } + if call.args[0] != int64(1) || call.args[1] != at { + t.Errorf("args: want [1 %v], got %v", at, call.args) + } + }) + + t.Run("no matching interval returns nil,nil", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{noRow()}} + got, err := newRepo(pool).GetActiveRateAt(context.Background(), 1, at) + if err != nil || got != nil { + t.Fatalf("got=%v err=%v, want nil,nil", got, err) + } + }) + + t.Run("scan error wrapped", func(t *testing.T) { + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{scanErr: errBoom}}} + _, err := newRepo(pool).GetActiveRateAt(context.Background(), 1, at) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// ListActiveRatesNow +// --------------------------------------------------------------------------- + +func TestListActiveRatesNow(t *testing.T) { + r1 := &systemmodel.GeofenceRate{ID: 1, GeofenceID: 1, RatePerWh: 0.0001, Currency: "USD", EffectiveFrom: fixedTime, CreatedAt: fixedTime} + + t.Run("queries the now()-open interval across all geofences", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows([][]any{geofenceRateRowVals(r1)})}}} + got, err := newRepo(pool).ListActiveRatesNow(context.Background()) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 || got[0].ID != 1 { + t.Fatalf("unexpected rates: %+v", got) + } + call := pool.queryCalls[0] + for _, sub := range []string{"effective_from <= now()", "effective_to IS NULL OR effective_to > now()", "ORDER BY geofence_id"} { + if !strings.Contains(call.sql, sub) { + t.Errorf("SQL missing %q:\n%s", sub, call.sql) + } + } + if len(call.args) != 0 { + t.Errorf("ListActiveRatesNow should take no args, got %v", call.args) + } + }) + + t.Run("query error wrapped", func(t *testing.T) { + pool := &fakePool{queryQueue: []queryResult{{err: errBoom}}} + _, err := newRepo(pool).ListActiveRatesNow(context.Background()) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) + + t.Run("scan error wrapped", func(t *testing.T) { + rows := newFakeRows([][]any{geofenceRateRowVals(r1)}) + rows.scanErrAt = 0 + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + _, err := newRepo(pool).ListActiveRatesNow(context.Background()) + if err == nil || !strings.Contains(err.Error(), "list_active_now scan") { + t.Fatalf("err=%v, want list_active_now scan error", err) + } + }) + + t.Run("iteration error wrapped", func(t *testing.T) { + rows := newFakeRows([][]any{geofenceRateRowVals(r1)}) + rows.iterErr = errBoom + pool := &fakePool{queryQueue: []queryResult{{rows: rows}}} + _, err := newRepo(pool).ListActiveRatesNow(context.Background()) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} + +// --------------------------------------------------------------------------- +// DeleteRate +// --------------------------------------------------------------------------- + +func TestDeleteRate(t *testing.T) { + future := time.Now().UTC().Add(24 * time.Hour) + futureRateRow := func(id int64) []any { + return []any{id, int64(1), 0.0001005, "USD", future, (*time.Time)(nil), fixedTime, true} + } + + t.Run("cancels unused future schedule", func(t *testing.T) { + tx := &fakeTx{ + queryRowQueue: []pgx.Row{ + fakeRow{vals: futureRateRow(5)}, + fakeRow{vals: []any{false}}, + }, + execQueue: []execResult{{tag: tag(1)}, {tag: tag(1)}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + if err := newRepo(pool).DeleteRate(context.Background(), 1, 5); err != nil { + t.Fatalf("unexpected err: %v", err) + } + call := tx.execCalls[0] + if !strings.Contains(call.sql, "DELETE FROM geofence_rates") { + t.Errorf("unexpected SQL: %s", call.sql) + } + if call.args[0] != int64(5) || call.args[1] != int64(1) { + t.Errorf("args: want [5 1], got %v", call.args) + } + if tx.commitCalls != 1 { + t.Fatalf("want 1 commit, got %d", tx.commitCalls) + } + }) + + t.Run("missing rate returns ErrRateNotFound", func(t *testing.T) { + tx := &fakeTx{queryRowQueue: []pgx.Row{noRow()}} + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).DeleteRate(context.Background(), 1, 999) + if !errors.Is(err, ErrRateNotFound) { + t.Fatalf("err=%v, want ErrRateNotFound", err) + } + }) + + t.Run("effective rate is immutable", func(t *testing.T) { + effective := time.Now().UTC().Add(-time.Hour) + row := futureRateRow(5) + row[4] = effective + row[7] = false + tx := &fakeTx{queryRowQueue: []pgx.Row{fakeRow{vals: row}}} + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).DeleteRate(context.Background(), 1, 5) + if !errors.Is(err, ErrRateImmutable) { + t.Fatalf("err=%v, want ErrRateImmutable", err) + } + }) + + t.Run("referenced future rate cannot be cancelled", func(t *testing.T) { + tx := &fakeTx{queryRowQueue: []pgx.Row{ + fakeRow{vals: futureRateRow(5)}, + fakeRow{vals: []any{true}}, + }} + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).DeleteRate(context.Background(), 1, 5) + if !errors.Is(err, ErrRateInUse) { + t.Fatalf("err=%v, want ErrRateInUse", err) + } + }) + + t.Run("restores adjacent predecessor before deleting schedule", func(t *testing.T) { + tx := &fakeTx{ + queryRowQueue: []pgx.Row{ + fakeRow{vals: futureRateRow(5)}, + fakeRow{vals: []any{false}}, + }, + execQueue: []execResult{{tag: tag(1)}, {tag: tag(1)}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + if err := newRepo(pool).DeleteRate(context.Background(), 1, 5); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(tx.execCalls) != 2 || !strings.Contains(tx.execCalls[1].sql, "UPDATE geofence_rates") { + t.Fatalf("expected DELETE then predecessor UPDATE, got %+v", tx.execCalls) + } + if tx.execCalls[1].args[2] != (*time.Time)(nil) { + t.Errorf("open schedule cancellation must restore predecessor to open-ended") + } + }) + + t.Run("delete error is wrapped", func(t *testing.T) { + tx := &fakeTx{ + queryRowQueue: []pgx.Row{fakeRow{vals: futureRateRow(5)}, fakeRow{vals: []any{false}}}, + execQueue: []execResult{{err: errBoom}}, + } + pool := &fakePool{beginQueue: []beginResult{{tx: tx}}} + err := newRepo(pool).DeleteRate(context.Background(), 1, 5) + if !errors.Is(err, errBoom) { + t.Fatalf("err=%v, want wrapped errBoom", err) + } + }) +} diff --git a/internal/database/geofence/repo_test.go b/internal/database/geofence/repo_test.go index 70b76f326c..7eb0934c96 100644 --- a/internal/database/geofence/repo_test.go +++ b/internal/database/geofence/repo_test.go @@ -126,8 +126,8 @@ func TestCreate(t *testing.T) { t.Errorf("SQL missing %q:\n%s", sub, got.sql) } } - if len(got.args) != 7 { - t.Fatalf("want 7 args, got %d (%v)", len(got.args), got.args) + if len(got.args) != 9 { + t.Fatalf("want 9 args, got %d (%v)", len(got.args), got.args) } if got.args[0] != tc.input.Name { t.Errorf("args[0] name: want %q got %v", tc.input.Name, got.args[0]) @@ -161,7 +161,7 @@ func TestGetAll(t *testing.T) { } assertGeofenceEqual(t, got[0], g1) assertGeofenceEqual(t, got[1], g2) - if sql := pool.queryCalls[0].sql; !strings.Contains(sql, "FROM geofences") || !strings.Contains(sql, "ORDER BY name LIMIT 500") { + if sql := pool.queryCalls[0].sql; !strings.Contains(sql, "FROM geofences") || !strings.Contains(sql, "ORDER BY archived_at NULLS FIRST, name LIMIT 500") { t.Errorf("unexpected SQL: %s", sql) } if len(pool.queryCalls[0].args) != 0 { @@ -272,13 +272,13 @@ func TestUpdate(t *testing.T) { t.Fatalf("want 1 Exec, got %d", len(pool.execCalls)) } call := pool.execCalls[0] - for _, sub := range []string{"UPDATE geofences", "WHERE id=$1", "updated_at=$8"} { + for _, sub := range []string{"UPDATE geofences", "WHERE id=$1", "updated_at=$9"} { if !strings.Contains(call.sql, sub) { t.Errorf("SQL missing %q:\n%s", sub, call.sql) } } - if len(call.args) != 8 { - t.Fatalf("want 8 args, got %d (%v)", len(call.args), call.args) + if len(call.args) != 9 { + t.Fatalf("want 9 args, got %d (%v)", len(call.args), call.args) } if call.args[0] != int64(5) { t.Errorf("args[0] id: want 5 got %v", call.args[0]) @@ -360,10 +360,37 @@ func TestFindByCoordinates_FiltersByRadiusNotEnabled(t *testing.T) { if got[1].Enabled { t.Fatalf("expected the second hit to be the DISABLED fence") } - // FindByCoordinates loads every row: no WHERE clause, no bound args. + // FindByCoordinates loads every active row: a WHERE archived_at IS NULL + // clause is expected (see method doc), but still no bound args since the + // filter is a static NULL check, not a parameter. call := pool.queryCalls[0] - if strings.Contains(call.sql, "WHERE") || len(call.args) != 0 { - t.Errorf("expected argument-free full scan, got sql=%q args=%v", call.sql, call.args) + if !strings.Contains(call.sql, "WHERE archived_at IS NULL") || len(call.args) != 0 { + t.Errorf("expected archived-filtered argument-free full scan, got sql=%q args=%v", call.sql, call.args) + } +} + +func TestFindByCoordinates_PrefersManualPlaceWhenGeofencesOverlap(t *testing.T) { + discovered := &systemmodel.Geofence{ + ID: 2, Name: "Auto", PolygonWKT: squareWKT(40, -75), + Origin: systemmodel.GeofenceOriginChargingDiscovery, + CreatedAt: fixedTime, UpdatedAt: fixedTime, + } + manual := &systemmodel.Geofence{ + ID: 9, Name: "Home", PolygonWKT: squareWKT(40, -75), + Origin: systemmodel.GeofenceOriginManual, + CreatedAt: fixedTime, UpdatedAt: fixedTime, + } + pool := &fakePool{queryQueue: []queryResult{{rows: newFakeRows([][]any{ + geofenceRowVals(discovered), + geofenceRowVals(manual), + })}}} + + got, err := newRepo(pool).FindByCoordinates(context.Background(), 40, -75) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 2 || got[0].ID != manual.ID { + t.Fatalf("manual place must win overlap matching, got %+v", got) } } diff --git a/internal/fsm/charge/machine_test.go b/internal/fsm/charge/machine_test.go index 21fa05058d..c724627195 100644 --- a/internal/fsm/charge/machine_test.go +++ b/internal/fsm/charge/machine_test.go @@ -56,11 +56,11 @@ func TestPending_ChargerInfoCaptured(t *testing.T) { func TestActive_AccumulatesEnergy(t *testing.T) { m := newActiveFSM() - m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 5.2}) - m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 10.4}) + m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 5200.0}) + m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 10400.0}) ctx := m.Context() - if ctx.EnergyAdded != 10.4 { - t.Fatalf("expected 10.4 kWh, got %f", ctx.EnergyAdded) + if ctx.EnergyAdded != 10400 { + t.Fatalf("expected 10400 Wh, got %f", ctx.EnergyAdded) } } @@ -69,12 +69,12 @@ func TestActive_AccumulatesVoltageCurrentPower(t *testing.T) { m.ProcessSignals(map[string]interface{}{ "ChargerVoltage": 240, "ChargerActualCurrent": 32, - "ACChargingPower": 7.6, + "ACChargingPower": 7600.0, }) m.ProcessSignals(map[string]interface{}{ "ChargerVoltage": 241, "ChargerActualCurrent": 31, - "ACChargingPower": 7.4, + "ACChargingPower": 7400.0, }) ctx := m.Context() if ctx.VoltageSamples != 2 { @@ -119,7 +119,7 @@ func TestCompleting_EndFieldsPresent_TransitionsToDone(t *testing.T) { m := newCompletingFSM() m.ProcessSignals(map[string]interface{}{ "BatteryLevel": 80, - "DCChargingEnergyIn": 25.0, + "DCChargingEnergyIn": 25000.0, }) if m.State() != Done { t.Fatalf("expected Done, got %s", m.State()) @@ -158,7 +158,7 @@ func TestRecovered_ChargeStillActive_TransitionsToActive(t *testing.T) { if m.State() != Recovered { t.Fatalf("expected Recovered, got %s", m.State()) } - m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 12.0}) + m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 12000.0}) if m.State() != Active { t.Fatalf("expected Active, got %s", m.State()) } @@ -186,7 +186,7 @@ func TestValidation_NoEnergyIn5Min(t *testing.T) { } func TestValidation_Energy150kWh(t *testing.T) { - c := &Context{StartBattery: 10, EndBattery: 100, EnergyAdded: 200, + c := &Context{StartBattery: 10, EndBattery: 100, EnergyAdded: 200_000, StartTime: time.Now().Add(-time.Hour), EndTime: time.Now()} issues := Validate(c) assertContains(t, issues, "energy > 150 kWh") @@ -194,7 +194,7 @@ func TestValidation_Energy150kWh(t *testing.T) { func TestValidation_AllGood(t *testing.T) { c := &Context{ - StartBattery: 45, EndBattery: 80, EnergyAdded: 25, + StartBattery: 45, EndBattery: 80, EnergyAdded: 25_000, StartTime: time.Now().Add(-45 * time.Minute), EndTime: time.Now(), } issues := Validate(c) @@ -223,10 +223,10 @@ func newDoneFSM() *SessionFSM { m.mu.Lock() m.ctx.StartTime = time.Now().UTC().Add(-45 * time.Minute) m.mu.Unlock() - m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 20.0, "BatteryLevel": 65}) + m.ProcessSignals(map[string]interface{}{"DCChargingEnergyIn": 20000.0, "BatteryLevel": 65}) m.TriggerEnding(map[string]interface{}{ "BatteryLevel": 80, - "DCChargingEnergyIn": 25.0, + "DCChargingEnergyIn": 25000.0, }, false) return m } diff --git a/internal/fsm/charge/state.go b/internal/fsm/charge/state.go index 345fdc3fa2..fd5160b57f 100644 --- a/internal/fsm/charge/state.go +++ b/internal/fsm/charge/state.go @@ -67,15 +67,15 @@ type Context struct { Phases int MaxVoltage int MaxCurrent int - MaxPower float64 + MaxPower float64 // W // Accumulation - EnergyAdded float64 // kWh + EnergyAdded float64 // Wh VoltageSum float64 VoltageSamples int CurrentSum float64 CurrentSamples int - PowerSum float64 + PowerSum float64 // sum of W samples PowerSamples int InsideTempSum float64 OutsideTempSum float64 @@ -112,7 +112,7 @@ func (c *Context) BatteryGain() int { return c.EndBattery - c.StartBattery } -// AvgPower returns average charge power in kW. +// AvgPower returns average charge power in W. func (c *Context) AvgPower() float64 { if c.PowerSamples == 0 { return 0 @@ -131,7 +131,7 @@ func Validate(c *Context) []string { if c.EnergyAdded <= 0 && dur > 5*time.Minute { issues = append(issues, "no energy added in >5min charge") } - if c.EnergyAdded > 150 { + if c.EnergyAdded > 150_000 { issues = append(issues, "energy > 150 kWh — suspicious") } if dur < 1*time.Minute { @@ -139,7 +139,7 @@ func Validate(c *Context) []string { } if dur.Hours() > 0 && c.EnergyAdded > 0 { rate := c.EnergyAdded / dur.Hours() - if rate > 350 { + if rate > 350_000 { issues = append(issues, "charge rate > 350 kW — suspicious") } } diff --git a/internal/metrics/metrics_geofence_pricing.go b/internal/metrics/metrics_geofence_pricing.go new file mode 100644 index 0000000000..f404275a67 --- /dev/null +++ b/internal/metrics/metrics_geofence_pricing.go @@ -0,0 +1,61 @@ +// Package metrics — charging-place (geofence) discovery + rate-based +// pricing counters for the geofence-based charging-place pricing feature +// (migration 000228_geofence_charging_place_pricing). +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// ── Charging-place discovery/pricing ────────────────────────────────────── +// +// These counters are incremented from the async post-completion tracker and +// the one-shot startup history backfill. They make discovery/pricing trouble +// visible even though neither path may fail charge completion or MQTT ingest. + +var ( + // GeofenceDiscoveryTotal counts charging-session geofence resolution + // attempts by outcome: + // - matched: an existing (manual or previously-discovered) geofence + // already contained the session's coordinates. + // - created: no geofence matched, so a new provisional + // charging-place geofence was created (origin=charging_discovery). + // - error: coordinates were invalid/missing, or the match-or-create + // database operation failed; the session is left unattributed. + GeofenceDiscoveryTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "teslasync", + Name: "geofence_discovery_total", + Help: "Charging-place geofence discovery attempts from confirmed charging sessions, by outcome", + }, []string{"result"}) + + // GeofenceRateApplyTotal counts charging-session rate-based cost + // application attempts by outcome: + // - applied: a configured rate was found and the session was priced + // (or re-priced) via PostgreSQL NUMERIC arithmetic. + // - no_rate: the geofence resolved but has no rate configured for + // the session's started_at yet. + // - skipped: a rate was found but the session was not updated + // because it already carries a manual/Tesla-actual cost, or has + // no energy-added value yet to price against. + // - error: the rate lookup or apply database operation failed. + GeofenceRateApplyTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "teslasync", + Name: "geofence_rate_apply_total", + Help: "Charging session geofence-tariff pricing attempts, by outcome", + }, []string{"result"}) + + // GeofenceHistoricalBackfillTotal counts completed legacy charging + // sessions processed by the startup Charging Places backfill: + // - historical_rate: priced with the rate active at session start. + // - current_estimate: no historical rate existed, so today's rate was + // applied with default_estimate provenance. + // - attributed_only: place attached but no usable rate was configured. + // - skipped: place attached, monetary fields protected or incomplete. + // - error: discovery, attribution, lookup, or pricing failed. + GeofenceHistoricalBackfillTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "teslasync", + Name: "geofence_historical_backfill_total", + Help: "Legacy charging sessions processed by Charging Places startup backfill, by outcome", + }, []string{"result"}) +) diff --git a/internal/models/charging/charging.go b/internal/models/charging/charging.go index e98053349a..5af13e8204 100644 --- a/internal/models/charging/charging.go +++ b/internal/models/charging/charging.go @@ -27,6 +27,25 @@ type ChargingSession struct { CostCurrency *string `db:"cost_currency" json:"cost_currency,omitempty"` ChargerType *string `db:"charger_type" json:"charger_type,omitempty"` CableType *string `db:"cable_type" json:"cable_type,omitempty"` + + // Charging-place pricing provenance (migration + // 000228_geofence_charging_place_pricing). No DB-level FK on + // GeofenceID/RateID — these are written from the async (post-completion) + // leg of the telemetry charge tracker and must never be blocked by a + // synchronous FK check, mirroring the existing no-FK precedent between + // charging_telemetry and charging_sessions.id. + // + // GeofenceID is the charging place this session was matched (or + // auto-discovered) to. RateID is the EXACT geofence_rates version whose + // [effective_from, effective_to) interval contained StartedAt at the + // moment cost was computed — pinning it (rather than re-deriving it from + // StartedAt on every read) is what keeps a session's price frozen forever + // even after the place's rate is corrected or superseded. CostSource + // records provenance/precedence: "manual" > "tesla_actual" > + // "geofence_tariff" > "default_estimate" > "unknown". + GeofenceID *int64 `db:"geofence_id" json:"geofence_id,omitempty"` + RateID *int64 `db:"rate_id" json:"rate_id,omitempty"` + CostSource *string `db:"cost_source" json:"cost_source,omitempty"` } // IsActive reports whether the charging session is still in progress diff --git a/internal/models/drive/drive.go b/internal/models/drive/drive.go index d84940d385..8938b2b12b 100644 --- a/internal/models/drive/drive.go +++ b/internal/models/drive/drive.go @@ -42,6 +42,18 @@ type Drive struct { EndLat *float64 `db:"end_lat" json:"end_lat,omitempty"` EndLon *float64 `db:"end_lon" json:"end_lon,omitempty"` + // StartGeofenceID / EndGeofenceID identify the charging-place geofence + // (if any) matched at each drive endpoint (migration + // 000228_geofence_charging_place_pricing). Match-only — a drive never + // auto-creates a geofence. No DB-level FK, for the same hot-path-adjacent + // reason as StartAddress/EndAddress resolution: written from + // resolveAndUpdateAddress, which must not be blocked by a synchronous FK + // check. Renaming the geofence later improves historical display because + // readers resolve the CURRENT geofence name at read time, falling back to + // the stored StartAddress/EndAddress text when no geofence matched. + StartGeofenceID *int64 `db:"start_geofence_id" json:"start_geofence_id,omitempty"` + EndGeofenceID *int64 `db:"end_geofence_id" json:"end_geofence_id,omitempty"` + StartBatteryPct *int16 `db:"start_battery_pct" json:"start_battery_pct,omitempty"` EndBatteryPct *int16 `db:"end_battery_pct" json:"end_battery_pct,omitempty"` diff --git a/internal/models/system/geofence_geometry.go b/internal/models/system/geofence_geometry.go new file mode 100644 index 0000000000..3b3e6cef58 --- /dev/null +++ b/internal/models/system/geofence_geometry.go @@ -0,0 +1,37 @@ +package system + +import ( + "fmt" + "math" + "strings" +) + +const geofenceCircleSegments = 32 + +// CircleToPolygonWKT approximates a geodetic circle with a regular polygon +// and emits WKT longitude/latitude coordinates, closing the ring. +func CircleToPolygonWKT(latDeg, lonDeg, radiusMeters float64) string { + const metersPerDegLat = 111_320.0 + latRad := latDeg * math.Pi / 180.0 + metersPerDegLon := metersPerDegLat * math.Cos(latRad) + if metersPerDegLon < 1 { + metersPerDegLon = 1 + } + dLat := radiusMeters / metersPerDegLat + dLon := radiusMeters / metersPerDegLon + + var b strings.Builder + b.WriteString("POLYGON((") + for i := 0; i < geofenceCircleSegments; i++ { + theta := 2 * math.Pi * float64(i) / float64(geofenceCircleSegments) + lon := lonDeg + dLon*math.Sin(theta) + lat := latDeg + dLat*math.Cos(theta) + if i > 0 { + b.WriteByte(',') + } + fmt.Fprintf(&b, "%.7f %.7f", lon, lat) + } + fmt.Fprintf(&b, ",%.7f %.7f", lonDeg, latDeg+dLat) + b.WriteString("))") + return b.String() +} diff --git a/internal/models/system/geofence_rate.go b/internal/models/system/geofence_rate.go new file mode 100644 index 0000000000..7a3d79bbe4 --- /dev/null +++ b/internal/models/system/geofence_rate.go @@ -0,0 +1,146 @@ +package system + +import ( + "time" +) + +// ============================================================================= +// geofence_rate.go — Go models for the charging-place pricing feature +// (migration 000228_geofence_charging_place_pricing). +// +// GeofenceRate is the single normalized, effective-dated source of truth for +// a geofence's electricity rate over time — there is no separate mutable +// "current rate" column anywhere. The "current" rate for a geofence is +// simply whichever row's [EffectiveFrom, EffectiveTo) interval contains the +// query instant (typically "now", or a charging session's StartedAt for +// historical pricing). +// +// RatePerWh is SI-canonical: currency units per **watt-hour**, never +// per-kWh. The UI converts to currency/kWh only at the render/request +// boundary (ADR-001 typed-by-default, ADR-005 frontend SI cutover). +// ============================================================================= + +// GeofenceRate mirrors one row of the `geofence_rates` table. +type GeofenceRate struct { + ID int64 `db:"id" json:"id"` + GeofenceID int64 `db:"geofence_id" json:"geofence_id"` + RatePerWh float64 `db:"rate_per_wh" json:"rate_per_wh"` + Currency string `db:"currency" json:"currency"` + EffectiveFrom time.Time `db:"effective_from" json:"effective_from"` + EffectiveTo *time.Time `db:"effective_to" json:"effective_to,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +// IsActiveAt reports whether this rate version was in force at instant t, +// using the canonical half-open [EffectiveFrom, EffectiveTo) interval. +func (r *GeofenceRate) IsActiveAt(t time.Time) bool { + if r == nil { + return false + } + if t.Before(r.EffectiveFrom) { + return false + } + return r.EffectiveTo == nil || t.Before(*r.EffectiveTo) +} + +// IsOpen reports whether this rate version has no end date yet (the +// currently-in-force version, absent a future-dated successor). +func (r *GeofenceRate) IsOpen() bool { return r != nil && r.EffectiveTo == nil } + +// Charging-session cost provenance. Mirrors the values allowed by the +// charging_sessions.cost_source CHECK constraint (migration 000228). +// +// Precedence (highest to lowest confidence): manual actual costs entered by +// a user, then Tesla-reported actual costs, then geofence-tariff-derived +// costs, then a legacy/global default estimate, then unknown (unpriced). +const ( + CostSourceManual = "manual" + CostSourceTeslaActual = "tesla_actual" + CostSourceGeofenceTariff = "geofence_tariff" + CostSourceDefaultEstimate = "default_estimate" + CostSourceUnknown = "unknown" +) + +// GeofenceRateApplyScope bounds a preview/apply operation to a geofence + +// rate version, optionally narrowed further by an explicit time window. The +// zero value (nil bounds) means "the rate's own effective interval". +// +// This is an internal argument bag passed between the handler and repo +// layers (never persisted or unmarshaled from a request body directly), but +// it still carries json tags per ADR-006's blanket models-package rule and +// so it serializes sensibly if ever logged or echoed back. +type GeofenceRateApplyScope struct { + GeofenceID int64 `json:"geofence_id"` + RateID int64 `json:"rate_id"` + From *time.Time `json:"from,omitempty"` + To *time.Time `json:"to,omitempty"` +} + +// GeofenceRateImpactPreview is the read-only "what would applying this rate +// do" response — no rows are written. EligibleSessions is the subset of +// MatchedSessions that repricing is actually allowed to touch (unpriced or +// previously geofence-derived); sessions already carrying a manual, +// Tesla-actual, or unknown-provenance cost are matched (in-scope by place + +// time) but never eligible, and are surfaced separately so the UI can +// explain why the preview total is smaller than the raw session count. +type GeofenceRateImpactPreview struct { + GeofenceID int64 `json:"geofence_id"` + RateID int64 `json:"rate_id"` + Currency string `json:"currency"` + MatchedSessions int64 `json:"matched_sessions"` + EligibleSessions int64 `json:"eligible_sessions"` + ProtectedSessions int64 `json:"protected_sessions"` + TotalEnergyWh float64 `json:"total_energy_wh"` + EstimatedCostDecimal float64 `json:"estimated_cost_decimal"` +} + +// GeofenceRateApplyResult is the outcome of an explicit apply/backfill +// action — the write-performing counterpart of GeofenceRateImpactPreview. +type GeofenceRateApplyResult struct { + GeofenceID int64 `json:"geofence_id"` + RateID int64 `json:"rate_id"` + Currency string `json:"currency"` + MatchedSessions int64 `json:"matched_sessions"` + PricedSessions int64 `json:"priced_sessions"` + SkippedSessions int64 `json:"skipped_sessions"` + TotalEnergyWh float64 `json:"total_energy_wh"` + TotalCostDecimal float64 `json:"total_cost_decimal"` +} + +// ChargingPlaceBackfillCandidate is the minimal completed-session projection +// needed by the startup Charging Places backfill. Only sessions with valid +// coordinates and no geofence attribution are returned by the repository. +type ChargingPlaceBackfillCandidate struct { + SessionID int64 `db:"id" json:"session_id"` + VehicleID int64 `db:"vehicle_id" json:"vehicle_id"` + StartedAt time.Time `db:"started_at" json:"started_at"` + StartLat float64 `db:"start_lat" json:"start_lat"` + StartLng float64 `db:"start_lng" json:"start_lng"` + StartPlace *string `db:"start_place" json:"start_place,omitempty"` +} + +// GeofenceChargingSummary aggregates a geofence's priced charging activity +// for one currency. Different currencies are NEVER summed into one total — +// callers always receive a slice grouped by currency, even when a place has +// only ever seen one. +type GeofenceChargingSummary struct { + GeofenceID int64 `json:"geofence_id"` + Currency string `json:"currency"` + SessionCount int64 `json:"session_count"` + TotalEnergyWh float64 `json:"total_energy_wh"` + TotalCostDecimal float64 `json:"total_cost_decimal"` +} + +// GeofenceChargingActivity is one line item in a geofence's charging session +// activity feed (used by the rate-history / affected-sessions UI panels). +type GeofenceChargingActivity struct { + SessionID int64 `json:"session_id"` + VehicleID int64 `json:"vehicle_id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + EnergyWh *float64 `json:"energy_wh,omitempty"` + CostDecimal *float64 `json:"cost_decimal,omitempty"` + CostCurrency *string `json:"cost_currency,omitempty"` + CostSource *string `json:"cost_source,omitempty"` + RateID *int64 `json:"rate_id,omitempty"` +} diff --git a/internal/models/system/system.go b/internal/models/system/system.go index b92afefeb4..8a17b0a090 100644 --- a/internal/models/system/system.go +++ b/internal/models/system/system.go @@ -84,6 +84,18 @@ const ( GeofenceCategoryCustom GeofenceCategory = "custom" ) +// GeofenceOrigin enumerates how a geofence came to exist. +type GeofenceOrigin string + +const ( + // GeofenceOriginManual is the default: a user created the place directly + // (Add Geofence modal, import, API). + GeofenceOriginManual GeofenceOrigin = "manual" + // GeofenceOriginChargingDiscovery marks a place that was auto-created from + // a confirmed charging session's coordinates (see AUTO-DISCOVERY rules). + GeofenceOriginChargingDiscovery GeofenceOrigin = "charging_discovery" +) + // Geofence mirrors the post-migration `geofences` schema. PolygonWKT is a // Well-Known Text POLYGON((lon lat, ...)) parsed at runtime — not server-side. // @@ -92,6 +104,18 @@ const ( // `enabled=false`; alert consumers (FSM transitions, notification dispatcher) // MUST filter on Enabled themselves — FindByCoordinates intentionally returns // disabled fences too because the reverse-geocoder uses it for friendly naming. +// +// Origin / NeedsReview / ArchivedAt were added by migration +// 000228_geofence_charging_place_pricing (charging-place pricing feature): +// - Origin distinguishes a user-created place from one auto-discovered off +// a confirmed charging session (see internal/database/geofence's +// discovery repo). Existing manual geofences default to "manual". +// - NeedsReview flags a provisional/auto-discovered place awaiting a +// human to confirm name/type/location — surfaced as the "Needs Setup" +// queue in the Charging Places UI. +// - ArchivedAt marks a place as retired without hard-deleting it: places +// with sessions/rates keep their history resolvable, but are excluded +// from default active listings (GetAll). type Geofence struct { ID int64 `db:"id" json:"id"` Name string `db:"name" json:"name"` @@ -100,10 +124,18 @@ type Geofence struct { Enabled bool `db:"enabled" json:"enabled"` AlertOnEntry bool `db:"alert_on_entry" json:"alert_on_entry"` AlertOnExit bool `db:"alert_on_exit" json:"alert_on_exit"` + Origin GeofenceOrigin `db:"origin" json:"origin"` + NeedsReview bool `db:"needs_review" json:"needs_review"` + ArchivedAt *time.Time `db:"archived_at" json:"archived_at,omitempty"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +// IsArchived reports whether this geofence has been archived (soft-deleted). +// Archived places are excluded from default active listings but remain +// resolvable by ID for historical display. +func (g *Geofence) IsArchived() bool { return g != nil && g.ArchivedAt != nil } + // Centroid computes the arithmetic mean of the polygon vertices. // Returns (0, 0) for a nil receiver or if PolygonWKT is empty or unparseable. // WKT convention: coordinates are (longitude latitude). diff --git a/internal/service/vehicle_service.go b/internal/service/vehicle_service.go index 73a13b7a60..1d1a357758 100644 --- a/internal/service/vehicle_service.go +++ b/internal/service/vehicle_service.go @@ -222,10 +222,12 @@ func (s *VehicleService) BuildStateFromSignalStore(store *signal.Store, vehicle } } if f, ok := signal.Float64Value(all["ACChargingPower"]); ok { - state.ChargerPower = f + // VehicleState's established JSON contract exposes charger_power in + // kW; signal.Store is canonical W after telemetry normalization. + state.ChargerPower = f / 1000.0 } if f, ok := signal.Float64Value(all["DCChargingPower"]); ok && f > 0 { - state.ChargerPower = f + state.ChargerPower = f / 1000.0 } if f, ok := signal.Float64Value(all["ChargeRateMilePerHour"]); ok { state.ChargeRate = f diff --git a/internal/service/vehicle_service_test.go b/internal/service/vehicle_service_test.go index e4269dbc08..bdd65bb1ad 100644 --- a/internal/service/vehicle_service_test.go +++ b/internal/service/vehicle_service_test.go @@ -439,7 +439,7 @@ func TestBuildStateFromSignalStore_AcceptsPhase42CodecNumericTypes(t *testing.T) "OutsideTemp": float32(14.5), "Latitude": float32(37.4419), "Longitude": float32(-122.143), - "ACChargingPower": float32(7.2), + "ACChargingPower": float32(7200), "ChargeRateMilePerHour": float32(28.5), "TimeToFullCharge": float32(2.5), // Int3/Int4 codec fields → int32 in the live store diff --git a/internal/tesla/client_auth.go b/internal/tesla/client_auth.go index df33affe3f..58edf7bf63 100644 --- a/internal/tesla/client_auth.go +++ b/internal/tesla/client_auth.go @@ -4,10 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" + "strings" "time" "github.com/rs/zerolog/log" @@ -50,6 +52,32 @@ func (c *Client) ExpiresWithin(d time.Duration) bool { // ErrUnauthorized is returned when the Tesla API rejects the current token. var ErrUnauthorized = fmt.Errorf("unauthorized (401): token expired or invalid") +// ErrPartnerCredentialsMissing indicates that a partner-scoped Fleet API call +// cannot be attempted because Tesla client credentials are not configured. +// It is intentionally distinct from ErrUnauthorized, which represents the +// user's OAuth token. +var ErrPartnerCredentialsMissing = errors.New("Tesla partner credentials are not configured") + +type partnerScope string + +const ( + partnerScopeDefault partnerScope = "openid vehicle_device_data vehicle_location vehicle_cmds vehicle_charging_cmds" + partnerScopeVehicleSpecs partnerScope = "vehicle_specs" + partnerScopeVehiclePricing partnerScope = "vehicle_pricing_info" + partnerScopeEnterpriseManagement partnerScope = "enterprise_management" +) + +// partnerTokenError reports only the Fleet Auth HTTP status. Tesla's response +// body can contain authentication details and must never be propagated into +// application logs or handler responses. +type partnerTokenError struct { + statusCode int +} + +func (e *partnerTokenError) Error() string { + return fmt.Sprintf("partner token request failed with status %d", e.statusCode) +} + // ClientID returns the configured OAuth client ID. func (c *Client) ClientID() string { return c.clientID } @@ -59,9 +87,23 @@ func (c *Client) ClientSecret() string { return c.clientSec } // GetPartnerToken obtains a client_credentials token for partner-level API calls. // Partner tokens use a separate auth endpoint (fleet-auth) from the user OAuth flow. func (c *Client) GetPartnerToken(ctx context.Context) (token string, err error) { - ctx, span := startSpan(ctx, "tesla.GetPartnerToken") + return c.getPartnerToken(ctx, partnerScopeDefault) +} + +// getPartnerToken obtains a client_credentials token with one fixed, +// call-site-owned scope. Keeping this private prevents request callers from +// supplying arbitrary scopes while preserving GetPartnerToken's historical +// default scope set for partner registration and Fleet Telemetry operations. +func (c *Client) getPartnerToken(ctx context.Context, scope partnerScope) (token string, err error) { + ctx, span := startSpan(ctx, "tesla.GetPartnerToken", + attribute.String("tesla.partner.scope", string(scope)), + ) defer endSpan(span, &err) + if strings.TrimSpace(c.clientID) == "" || strings.TrimSpace(c.clientSec) == "" { + return "", ErrPartnerCredentialsMissing + } + // Partner token endpoint is fleet-auth, not auth.tesla.com. // Derive from base URL: https://fleet-api.prd.na.vn.cloud.tesla.com // → https://fleet-auth.prd.vn.cloud.tesla.com @@ -71,21 +113,17 @@ func (c *Client) GetPartnerToken(ctx context.Context) (token string, err error) "grant_type": {"client_credentials"}, "client_id": {c.clientID}, "client_secret": {c.clientSec}, - "scope": {"openid vehicle_device_data vehicle_location vehicle_cmds vehicle_charging_cmds"}, + "scope": {string(scope)}, "audience": {c.baseURL}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, partnerAuthURL+"/oauth2/v3/token", bytes.NewReader([]byte(formData.Encode()))) if err != nil { - return "", err + return "", fmt.Errorf("create partner token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - log.Debug(). - Str("url", partnerAuthURL+"/oauth2/v3/token"). - Str("client_id", c.clientID). - Str("audience", c.baseURL). - Msg("requesting partner token") + log.Debug().Str("scope", string(scope)).Msg("requesting Tesla partner token") resp, err := c.httpClient.Do(req) if err != nil { @@ -93,9 +131,12 @@ func (c *Client) GetPartnerToken(ctx context.Context) (token string, err error) } defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read partner token response: %w", err) + } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("partner token failed (%d): %s", resp.StatusCode, string(respBody)) + return "", &partnerTokenError{statusCode: resp.StatusCode} } var result struct { @@ -104,9 +145,20 @@ func (c *Client) GetPartnerToken(ctx context.Context) (token string, err error) if err := json.Unmarshal(respBody, &result); err != nil { return "", fmt.Errorf("decode partner token: %w", err) } + if strings.TrimSpace(result.AccessToken) == "" { + return "", errors.New("partner token response did not include an access token") + } return result.AccessToken, nil } +func partnerTokenStatus(err error) int { + var tokenErr *partnerTokenError + if errors.As(err, &tokenErr) { + return tokenErr.statusCode + } + return 0 +} + // partnerAuthURL derives the fleet-auth URL from the configured base URL. // e.g. https://fleet-api.prd.na.vn.cloud.tesla.com → https://fleet-auth.prd.vn.cloud.tesla.com // Falls back to https://fleet-auth.prd.vn.cloud.tesla.com if parsing fails. @@ -125,13 +177,64 @@ func (c *Client) partnerAuthURL() string { // doRequestWithToken performs an API request using a custom bearer token (e.g. partner token) // instead of the stored user access token. Runs through the circuit breaker and logging. func (c *Client) doRequestWithToken(ctx context.Context, method, path string, body io.Reader, token string) (respBody []byte, statusCode int, err error) { - ctx, span := startSpan(ctx, "tesla.HTTP "+method+" "+path, + return c.doRequestWithTokenOptions(ctx, method, path, body, token, partnerRequestOptions{ + telemetryPath: path, + captureBodies: true, + }) +} + +type partnerRequestOptions struct { + telemetryPath string + captureBodies bool +} + +type privatePartnerRequestError struct { + cause error +} + +func (e *privatePartnerRequestError) Error() string { + return "private Tesla partner request failed" +} + +func (e *privatePartnerRequestError) Unwrap() error { + return e.cause +} + +// doPrivateRequestWithToken performs a partner-token request without exposing +// the request/response body or concrete VIN-bearing path to audit callbacks or +// tracing. Opaque pricing/payer payloads and enterprise responses may contain +// PII, so callers must use a fixed route template for telemetryPath. +func (c *Client) doPrivateRequestWithToken( + ctx context.Context, + method, path string, + body io.Reader, + token, telemetryPath string, +) ([]byte, int, error) { + return c.doRequestWithTokenOptions(ctx, method, path, body, token, partnerRequestOptions{ + telemetryPath: telemetryPath, + captureBodies: false, + }) +} + +func (c *Client) doRequestWithTokenOptions( + ctx context.Context, + method, path string, + body io.Reader, + token string, + opts partnerRequestOptions, +) (respBody []byte, statusCode int, err error) { + telemetryPath := opts.telemetryPath + if telemetryPath == "" { + telemetryPath = path + } + + ctx, span := startSpan(ctx, "tesla.HTTP "+method+" "+telemetryPath, attribute.String("http.request.method", method), - attribute.String("tesla.api.path", path), + attribute.String("tesla.api.path", telemetryPath), attribute.String("tesla.token.kind", "partner"), ) defer func() { - recordHTTPStatus(span, method, c.baseURL+path, statusCode) + recordHTTPStatus(span, method, c.baseURL+telemetryPath, statusCode) endSpan(span, &err) }() @@ -144,13 +247,19 @@ func (c *Client) doRequestWithToken(ctx context.Context, method, path string, bo var reqBodyBytes []byte if body != nil { - reqBodyBytes, _ = io.ReadAll(body) + reqBodyBytes, err = io.ReadAll(body) + if err != nil { + return nil, 0, fmt.Errorf("read partner request body: %w", err) + } body = bytes.NewReader(reqBodyBytes) } result, cbErr := c.cb.Execute(func() (interface{}, error) { req, reqErr := http.NewRequestWithContext(ctx, method, url, body) if reqErr != nil { + if !opts.captureBodies { + return nil, &privatePartnerRequestError{cause: reqErr} + } return nil, fmt.Errorf("create request: %w", reqErr) } @@ -159,12 +268,18 @@ func (c *Client) doRequestWithToken(ctx context.Context, method, path string, bo resp, doErr := c.httpClient.Do(req) if doErr != nil { + if !opts.captureBodies { + return nil, &privatePartnerRequestError{cause: doErr} + } return nil, fmt.Errorf("do request: %w", doErr) } defer resp.Body.Close() data, readErr := io.ReadAll(resp.Body) if readErr != nil { + if !opts.captureBodies { + return nil, &privatePartnerRequestError{cause: readErr} + } return nil, fmt.Errorf("read body: %w", readErr) } @@ -193,7 +308,15 @@ func (c *Client) doRequestWithToken(ctx context.Context, method, path string, bo } } if c.logCallback != nil { - c.logCallback(method, url, errStatus, reqBodyBytes, errRespBody, durationMs, cbErr) + callbackURL := url + callbackReqBody := reqBodyBytes + callbackRespBody := errRespBody + if !opts.captureBodies { + callbackURL = c.baseURL + telemetryPath + callbackReqBody = nil + callbackRespBody = nil + } + c.logCallback(method, callbackURL, errStatus, callbackReqBody, callbackRespBody, durationMs, cbErr) } err = cbErr statusCode = errStatus @@ -204,7 +327,15 @@ func (c *Client) doRequestWithToken(ctx context.Context, method, path string, bo resp := result.(*apiResponse) if c.logCallback != nil { - c.logCallback(method, url, resp.StatusCode, reqBodyBytes, resp.Body, durationMs, nil) + callbackURL := url + callbackReqBody := reqBodyBytes + callbackRespBody := resp.Body + if !opts.captureBodies { + callbackURL = c.baseURL + telemetryPath + callbackReqBody = nil + callbackRespBody = nil + } + c.logCallback(method, callbackURL, resp.StatusCode, callbackReqBody, callbackRespBody, durationMs, nil) } respBody = resp.Body diff --git a/internal/tesla/client_energy_charging.go b/internal/tesla/client_energy_charging.go index 0cfd084887..62d66a0918 100644 --- a/internal/tesla/client_energy_charging.go +++ b/internal/tesla/client_energy_charging.go @@ -33,8 +33,8 @@ func (c *Client) GetChargingHistory(ctx context.Context, vin string, startTime, } params.Set("pageNo", strconv.Itoa(pageNo)) params.Set("pageSize", strconv.Itoa(pageSize)) - params.Set("sortBy", "chargeStartDateTime") - params.Set("sortOrder", "DESC") + // Tesla's sort-field allowlist is not stable; omit optional server-side + // sorting and let the repository/page order the persisted results. path := "/api/1/dx/charging/history?" + params.Encode() return c.doRequest(ctx, http.MethodGet, path, nil) diff --git a/internal/tesla/client_energy_charging_test.go b/internal/tesla/client_energy_charging_test.go new file mode 100644 index 0000000000..3847e2caf5 --- /dev/null +++ b/internal/tesla/client_energy_charging_test.go @@ -0,0 +1,73 @@ +package tesla + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetChargingHistoryOmitsUnsupportedSortParameters(t *testing.T) { + t.Parallel() + + const ( + vin = "5YJ3E1EA1KF000001" + startTime = "2026-05-08T20:39:45Z" + endTime = "2026-08-08T20:39:45Z" + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/api/1/dx/charging/history" { + t.Errorf("path = %q, want /api/1/dx/charging/history", r.URL.Path) + } + + query := r.URL.Query() + if got := query.Get("vin"); got != vin { + t.Errorf("vin = %q, want %q", got, vin) + } + if got := query.Get("startTime"); got != startTime { + t.Errorf("startTime = %q, want %q", got, startTime) + } + if got := query.Get("endTime"); got != endTime { + t.Errorf("endTime = %q, want %q", got, endTime) + } + if got := query.Get("pageNo"); got != "1" { + t.Errorf("pageNo = %q, want 1", got) + } + if got := query.Get("pageSize"); got != "50" { + t.Errorf("pageSize = %q, want 50", got) + } + if query.Has("sortBy") { + t.Errorf("unsupported sortBy parameter sent: %q", query.Get("sortBy")) + } + if query.Has("sortOrder") { + t.Errorf("sortOrder sent without a supported sortBy: %q", query.Get("sortOrder")) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"data":[],"hasMoreData":false}}`)) + })) + defer server.Close() + + client := newTestClient(server) + body, status, err := client.GetChargingHistory( + context.Background(), + vin, + startTime, + endTime, + 1, + 50, + ) + if err != nil { + t.Fatalf("GetChargingHistory() error = %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want %d", status, http.StatusOK) + } + if len(body) == 0 { + t.Fatal("response body is empty") + } +} diff --git a/internal/tesla/client_vehicle_data.go b/internal/tesla/client_vehicle_data.go index 7547311338..2545e024d2 100644 --- a/internal/tesla/client_vehicle_data.go +++ b/internal/tesla/client_vehicle_data.go @@ -4,13 +4,31 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" + "net/url" "strings" "go.opentelemetry.io/otel/attribute" ) +const ( + vehicleSpecsRouteTemplate = "/api/1/vehicles/{vin}/specs" + enterpriseRolesRouteTemplate = "/api/1/dx/enterprise/v1/{vin}/roles" + enterprisePayerRouteTemplate = "/api/1/dx/enterprise/v1/{vin}/payer" + vehiclePricingPath = "/api/1/dx/vehicles/pricing" +) + +// JSONRequestObject is an opaque, non-empty JSON object accepted by Tesla +// endpoints whose request schema is not publicly documented. RawMessage +// values preserve nested JSON without resorting to map[string]interface{}. +type JSONRequestObject map[string]json.RawMessage + +// ErrEmptyJSONRequestObject is returned before token acquisition or any Fleet +// API call when an opaque request object contains no fields. +var ErrEmptyJSONRequestObject = errors.New("Tesla request payload must be a non-empty JSON object") + // GetNearbyChargingSites returns charging sites near the vehicle's current location. // GET /api/1/vehicles/{vin}/nearby_charging_sites func (c *Client) GetNearbyChargingSites(ctx context.Context, vin string) ([]byte, int, error) { @@ -54,36 +72,137 @@ func (c *Client) GetMobileEnabled(ctx context.Context, vin string) ([]byte, int, // GetVehicleOptions calls GET /api/1/dx/vehicles/options?vin={vin}. func (c *Client) GetVehicleOptions(ctx context.Context, vin string) ([]byte, int, error) { - path := fmt.Sprintf("/api/1/dx/vehicles/options?vin=%s", vin) + path := fmt.Sprintf("/api/1/dx/vehicles/options?vin=%s", url.QueryEscape(vin)) return c.doRequest(ctx, http.MethodGet, path, nil) } // GetVehicleSpecs calls GET /api/1/vehicles/{vin}/specs using a partner token. // NOTE: This endpoint costs $0.10 per successful call — cache aggressively. -func (c *Client) GetVehicleSpecs(ctx context.Context, vin string) ([]byte, int, error) { - partnerToken, err := c.GetPartnerToken(ctx) +func (c *Client) GetVehicleSpecs(ctx context.Context, vin string) (body []byte, status int, err error) { + ctx, span := startSpan(ctx, "tesla.GetVehicleSpecs") + defer endSpan(span, &err) + + partnerToken, err := c.getPartnerToken(ctx, partnerScopeVehicleSpecs) if err != nil { - return nil, 0, fmt.Errorf("get partner token: %w", err) + return nil, partnerTokenStatus(err), fmt.Errorf("get vehicle specs partner token: %w", err) } - path := fmt.Sprintf("/api/1/vehicles/%s/specs", vin) - return c.doRequestWithToken(ctx, http.MethodGet, path, nil, partnerToken) + path := fmt.Sprintf("/api/1/vehicles/%s/specs", url.PathEscape(vin)) + return c.doPrivateRequestWithToken( + ctx, + http.MethodGet, + path, + nil, + partnerToken, + vehicleSpecsRouteTemplate, + ) } // GetSubscriptionEligibility calls GET /api/1/dx/vehicles/subscriptions/eligibility?vin={vin}. func (c *Client) GetSubscriptionEligibility(ctx context.Context, vin string) ([]byte, int, error) { - path := fmt.Sprintf("/api/1/dx/vehicles/subscriptions/eligibility?vin=%s", vin) + path := fmt.Sprintf("/api/1/dx/vehicles/subscriptions/eligibility?vin=%s", url.QueryEscape(vin)) return c.doRequest(ctx, http.MethodGet, path, nil) } // GetUpgradeEligibility calls GET /api/1/dx/vehicles/upgrades/eligibility?vin={vin}. func (c *Client) GetUpgradeEligibility(ctx context.Context, vin string) ([]byte, int, error) { - path := fmt.Sprintf("/api/1/dx/vehicles/upgrades/eligibility?vin=%s", vin) + path := fmt.Sprintf("/api/1/dx/vehicles/upgrades/eligibility?vin=%s", url.QueryEscape(vin)) + return c.doRequest(ctx, http.MethodGet, path, nil) +} + +// GetWarrantyDetails calls GET /api/1/dx/warranty/details?vin={vin}. +func (c *Client) GetWarrantyDetails(ctx context.Context, vin string) ([]byte, int, error) { + path := fmt.Sprintf("/api/1/dx/warranty/details?vin=%s", url.QueryEscape(vin)) return c.doRequest(ctx, http.MethodGet, path, nil) } -// GetWarrantyDetails calls GET /api/1/dx/warranty/details. -func (c *Client) GetWarrantyDetails(ctx context.Context) ([]byte, int, error) { - return c.doRequest(ctx, http.MethodGet, "/api/1/dx/warranty/details", nil) +// GetVehiclePricing calls POST /api/1/dx/vehicles/pricing with an opaque +// Tesla-controlled request object and the vehicle_pricing_info partner scope. +// The endpoint is a read-only query despite using POST. +func (c *Client) GetVehiclePricing( + ctx context.Context, + payload JSONRequestObject, +) (body []byte, status int, err error) { + ctx, span := startSpan(ctx, "tesla.GetVehiclePricing") + defer endSpan(span, &err) + + requestBody, err := marshalJSONRequestObject(payload) + if err != nil { + return nil, 0, err + } + partnerToken, err := c.getPartnerToken(ctx, partnerScopeVehiclePricing) + if err != nil { + return nil, partnerTokenStatus(err), fmt.Errorf("get vehicle pricing partner token: %w", err) + } + return c.doPrivateRequestWithToken( + ctx, + http.MethodPost, + vehiclePricingPath, + bytes.NewReader(requestBody), + partnerToken, + vehiclePricingPath, + ) +} + +// GetEnterpriseRoles calls GET /api/1/dx/enterprise/v1/{vin}/roles with the +// enterprise_management partner scope. +func (c *Client) GetEnterpriseRoles(ctx context.Context, vin string) (body []byte, status int, err error) { + ctx, span := startSpan(ctx, "tesla.GetEnterpriseRoles") + defer endSpan(span, &err) + + partnerToken, err := c.getPartnerToken(ctx, partnerScopeEnterpriseManagement) + if err != nil { + return nil, partnerTokenStatus(err), fmt.Errorf("get enterprise roles partner token: %w", err) + } + path := fmt.Sprintf("/api/1/dx/enterprise/v1/%s/roles", url.PathEscape(vin)) + return c.doPrivateRequestWithToken( + ctx, + http.MethodGet, + path, + nil, + partnerToken, + enterpriseRolesRouteTemplate, + ) +} + +// SetEnterprisePayer calls POST /api/1/dx/enterprise/v1/{vin}/payer with an +// opaque Tesla-controlled object. Callers must enforce explicit confirmation +// before invoking this state-changing operation. +func (c *Client) SetEnterprisePayer( + ctx context.Context, + vin string, + payload JSONRequestObject, +) (body []byte, status int, err error) { + ctx, span := startSpan(ctx, "tesla.SetEnterprisePayer") + defer endSpan(span, &err) + + requestBody, err := marshalJSONRequestObject(payload) + if err != nil { + return nil, 0, err + } + partnerToken, err := c.getPartnerToken(ctx, partnerScopeEnterpriseManagement) + if err != nil { + return nil, partnerTokenStatus(err), fmt.Errorf("get enterprise payer partner token: %w", err) + } + path := fmt.Sprintf("/api/1/dx/enterprise/v1/%s/payer", url.PathEscape(vin)) + return c.doPrivateRequestWithToken( + ctx, + http.MethodPost, + path, + bytes.NewReader(requestBody), + partnerToken, + enterprisePayerRouteTemplate, + ) +} + +func marshalJSONRequestObject(payload JSONRequestObject) ([]byte, error) { + if len(payload) == 0 { + return nil, ErrEmptyJSONRequestObject + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encode Tesla request object: %w", err) + } + return body, nil } // ListVehicles returns all vehicles associated with the authenticated Tesla account. diff --git a/internal/tesla/client_vehicle_management_test.go b/internal/tesla/client_vehicle_management_test.go new file mode 100644 index 0000000000..2a58a02d86 --- /dev/null +++ b/internal/tesla/client_vehicle_management_test.go @@ -0,0 +1,370 @@ +package tesla + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/config" +) + +type vehicleManagementRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn vehicleManagementRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +type capturedManagementRequest struct { + method string + host string + path string + authorization string + body string +} + +type capturedAuditCall struct { + url string + reqBody []byte + respBody []byte + status int +} + +func newVehicleManagementClient( + t *testing.T, + tokenStatus int, + apiStatus int, +) (*Client, *[]capturedManagementRequest) { + t.Helper() + + requests := []capturedManagementRequest{} + client := NewClient(config.TeslaConfig{ + BaseURL: "https://fleet-api.prd.na.vn.cloud.tesla.com", + AuthURL: "https://auth.tesla.com", + ClientID: "partner-client", + ClientSecret: "partner-secret", + RedirectURI: "https://example.test/callback", + Timeout: 5 * time.Second, + }) + client.httpClient = &http.Client{ + Timeout: 5 * time.Second, + Transport: vehicleManagementRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var body []byte + if req.Body != nil { + var err error + body, err = io.ReadAll(req.Body) + if err != nil { + t.Fatalf("read outbound body: %v", err) + } + } + requests = append(requests, capturedManagementRequest{ + method: req.Method, + host: req.URL.Host, + path: req.URL.RequestURI(), + authorization: req.Header.Get("Authorization"), + body: string(body), + }) + + status := apiStatus + responseBody := `{"response":{"ok":true}}` + if req.URL.Path == "/oauth2/v3/token" { + status = tokenStatus + responseBody = `{"access_token":"scoped-partner-token"}` + if status != http.StatusOK { + responseBody = `{"error":"sensitive-token-detail"}` + } + } + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + Request: req, + }, nil + }), + } + return client, &requests +} + +func TestVehicleManagementPartnerCallsUseExactScopeMethodPathAndBody(t *testing.T) { + payload := JSONRequestObject{ + "opaque": json.RawMessage(`{"nested":[1,true]}`), + } + tests := []struct { + name string + scope string + method string + path string + wantBody JSONRequestObject + invokeCall func(context.Context, *Client) ([]byte, int, error) + }{ + { + name: "paid specs", + scope: "vehicle_specs", + method: http.MethodGet, + path: "/api/1/vehicles/TESTVIN/specs", + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.GetVehicleSpecs(ctx, "TESTVIN") + }, + }, + { + name: "vehicle pricing", + scope: "vehicle_pricing_info", + method: http.MethodPost, + path: "/api/1/dx/vehicles/pricing", + wantBody: payload, + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.GetVehiclePricing(ctx, payload) + }, + }, + { + name: "enterprise roles", + scope: "enterprise_management", + method: http.MethodGet, + path: "/api/1/dx/enterprise/v1/TESTVIN/roles", + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.GetEnterpriseRoles(ctx, "TESTVIN") + }, + }, + { + name: "enterprise payer", + scope: "enterprise_management", + method: http.MethodPost, + path: "/api/1/dx/enterprise/v1/TESTVIN/payer", + wantBody: payload, + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.SetEnterprisePayer(ctx, "TESTVIN", payload) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, requests := newVehicleManagementClient(t, http.StatusOK, http.StatusOK) + auditCalls := []capturedAuditCall{} + client.SetLogCallback(func(_ string, requestURL string, statusCode int, reqBody, respBody []byte, _ int, _ error) { + auditCalls = append(auditCalls, capturedAuditCall{ + url: requestURL, + reqBody: reqBody, + respBody: respBody, + status: statusCode, + }) + }) + + _, status, err := tt.invokeCall(context.Background(), client) + if err != nil { + t.Fatalf("call returned error: %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if len(*requests) != 2 { + t.Fatalf("requests = %d, want token + API request", len(*requests)) + } + + tokenReq := (*requests)[0] + if tokenReq.method != http.MethodPost || tokenReq.path != "/oauth2/v3/token" { + t.Fatalf("token request = %s %s", tokenReq.method, tokenReq.path) + } + form, err := url.ParseQuery(tokenReq.body) + if err != nil { + t.Fatalf("parse token form: %v", err) + } + if got := form.Get("scope"); got != tt.scope { + t.Fatalf("scope = %q, want %q", got, tt.scope) + } + + apiReq := (*requests)[1] + if apiReq.method != tt.method || apiReq.path != tt.path { + t.Fatalf("API request = %s %s, want %s %s", apiReq.method, apiReq.path, tt.method, tt.path) + } + if apiReq.authorization != "Bearer scoped-partner-token" { + t.Fatalf("authorization = %q", apiReq.authorization) + } + if tt.wantBody == nil { + if apiReq.body != "" { + t.Fatalf("body = %q, want empty", apiReq.body) + } + } else { + var got JSONRequestObject + if err := json.Unmarshal([]byte(apiReq.body), &got); err != nil { + t.Fatalf("decode API body: %v", err) + } + if !reflect.DeepEqual(got, tt.wantBody) { + t.Fatalf("body = %#v, want %#v", got, tt.wantBody) + } + } + + if len(auditCalls) != 1 { + t.Fatalf("audit calls = %d, want 1", len(auditCalls)) + } + if auditCalls[0].status != http.StatusOK { + t.Fatalf("audit status = %d, want 200", auditCalls[0].status) + } + if len(auditCalls[0].reqBody) != 0 || len(auditCalls[0].respBody) != 0 { + t.Fatal("private management request or response body reached audit callback") + } + if strings.Contains(auditCalls[0].url, "TESTVIN") { + t.Fatalf("VIN reached audit callback URL: %q", auditCalls[0].url) + } + }) + } +} + +func TestVehicleManagementUserCallsUseExactMethodAndPath(t *testing.T) { + tests := []struct { + name string + path string + invokeCall func(context.Context, *Client) ([]byte, int, error) + }{ + { + name: "vehicle options", + path: "/api/1/dx/vehicles/options?vin=TESTVIN", + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.GetVehicleOptions(ctx, "TESTVIN") + }, + }, + { + name: "warranty details", + path: "/api/1/dx/warranty/details?vin=TESTVIN", + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.GetWarrantyDetails(ctx, "TESTVIN") + }, + }, + { + name: "subscription eligibility", + path: "/api/1/dx/vehicles/subscriptions/eligibility?vin=TESTVIN", + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.GetSubscriptionEligibility(ctx, "TESTVIN") + }, + }, + { + name: "upgrade eligibility", + path: "/api/1/dx/vehicles/upgrades/eligibility?vin=TESTVIN", + invokeCall: func(ctx context.Context, client *Client) ([]byte, int, error) { + return client.GetUpgradeEligibility(ctx, "TESTVIN") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, requests := newVehicleManagementClient(t, http.StatusOK, http.StatusOK) + client.SetTokens("user-access-token", "", time.Now().Add(time.Hour)) + + _, status, err := tt.invokeCall(context.Background(), client) + if err != nil { + t.Fatalf("call returned error: %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if len(*requests) != 1 { + t.Fatalf("requests = %d, want one Fleet API request", len(*requests)) + } + request := (*requests)[0] + if request.method != http.MethodGet || request.path != tt.path { + t.Fatalf( + "API request = %s %s, want GET %s", + request.method, + request.path, + tt.path, + ) + } + if request.authorization == "" { + t.Fatal("user-token request is missing authorization") + } + }) + } +} + +func TestGetPartnerTokenPreservesDefaultScopesAndUserOAuthScopes(t *testing.T) { + client, requests := newVehicleManagementClient(t, http.StatusOK, http.StatusOK) + + if _, err := client.GetPartnerToken(context.Background()); err != nil { + t.Fatalf("GetPartnerToken: %v", err) + } + if len(*requests) != 1 { + t.Fatalf("requests = %d, want 1", len(*requests)) + } + form, err := url.ParseQuery((*requests)[0].body) + if err != nil { + t.Fatalf("parse form: %v", err) + } + if got := form.Get("scope"); got != string(partnerScopeDefault) { + t.Fatalf("default scope = %q, want %q", got, partnerScopeDefault) + } + + userAuthURL := client.GetAuthURL("state") + for _, partnerOnly := range []string{ + "vehicle_specs", + "vehicle_pricing_info", + "enterprise_management", + } { + if strings.Contains(userAuthURL, partnerOnly) { + t.Fatalf("ordinary user OAuth URL contains partner-only scope %q: %s", partnerOnly, userAuthURL) + } + } +} + +func TestVehicleManagementClientRejectsEmptyObjectBeforeTokenCall(t *testing.T) { + client, requests := newVehicleManagementClient(t, http.StatusOK, http.StatusOK) + + _, _, err := client.SetEnterprisePayer(context.Background(), "TESTVIN", JSONRequestObject{}) + if !errors.Is(err, ErrEmptyJSONRequestObject) { + t.Fatalf("error = %v, want ErrEmptyJSONRequestObject", err) + } + if len(*requests) != 0 { + t.Fatalf("requests = %d, want 0", len(*requests)) + } +} + +func TestVehicleManagementClientPreservesNon2xxStatusWithoutBodies(t *testing.T) { + client, _ := newVehicleManagementClient(t, http.StatusOK, http.StatusForbidden) + auditCalls := []capturedAuditCall{} + client.SetLogCallback(func(_ string, requestURL string, statusCode int, reqBody, respBody []byte, _ int, _ error) { + auditCalls = append(auditCalls, capturedAuditCall{ + url: requestURL, reqBody: reqBody, respBody: respBody, status: statusCode, + }) + }) + + body, status, err := client.GetEnterpriseRoles(context.Background(), "TESTVIN") + if err != nil { + t.Fatalf("403 should be returned for handler mapping, got error: %v", err) + } + if status != http.StatusForbidden { + t.Fatalf("status = %d, want 403", status) + } + if !strings.Contains(string(body), `"ok"`) { + t.Fatalf("unexpected response body: %s", body) + } + if len(auditCalls) != 1 || len(auditCalls[0].respBody) != 0 { + t.Fatalf("private non-2xx response reached audit callback: %+v", auditCalls) + } +} + +func TestPartnerTokenFailureStatusFlowsToManagementCaller(t *testing.T) { + client, requests := newVehicleManagementClient(t, http.StatusForbidden, http.StatusOK) + + _, status, err := client.GetVehiclePricing( + context.Background(), + JSONRequestObject{"opaque": json.RawMessage(`true`)}, + ) + if err == nil { + t.Fatal("expected partner token error") + } + if status != http.StatusForbidden { + t.Fatalf("status = %d, want 403", status) + } + if strings.Contains(err.Error(), "sensitive-token-detail") { + t.Fatalf("partner token response leaked through error: %v", err) + } + if len(*requests) != 1 { + t.Fatalf("requests = %d, want token request only", len(*requests)) + } +} diff --git a/internal/tesla/normalize/normalize.go b/internal/tesla/normalize/normalize.go index 9b61928b3a..e3170516d4 100644 --- a/internal/tesla/normalize/normalize.go +++ b/internal/tesla/normalize/normalize.go @@ -107,12 +107,11 @@ var defaultMetrics = &Metrics{ }, []string{"field"}), } -// toSI converts a unit-bearing atomic to canonical SI given the -// active unit at the atomic's EmittedAt. For atomics whose Field is -// dimensionless (UnitKindNone and not on the speed-override list) -// or whose Field is UnitKindCharge (SoC scalars are always %), the -// function returns the atomic unchanged — these flow through the -// router with the producer-original Value. +// toSI converts a unit-bearing atomic to canonical SI given the active unit +// at the atomic's EmittedAt. Fixed-wire charging fields bypass unit history +// and convert kWh/kW to Wh/W directly. For atomics whose Field is dimensionless +// (UnitKindNone and not on an override list) or whose Field is UnitKindCharge +// (SoC scalars are always %), the function returns the atomic unchanged. // // Errors: // @@ -145,23 +144,19 @@ func (p *Pipeline) toSI(ctx context.Context, atomic codec.Atomic, vehicleIntID i return atomic, nil } - // Fixed-wire-unit fields (Odometer, RatedRange, etc.) bypass the - // unit-history lookup entirely: their wire value is always in - // miles regardless of SettingDistanceUnit, so units.ToSI applies - // the miles conversion with active="" and the atomic MUST NOT be - // dropped on unithistory.ErrNotFound. Without this bypass a - // fresh vehicle with no unit_history rows would silently drop - // every Odometer / range sample. See units.IsFixedMileDistanceField - // and the empirical evidence in conversions.go for why these - // fields do not follow the user setting. - if units.IsFixedMileDistanceField(atomic.Field) { + // Fixed-wire-unit fields bypass unit history entirely. Distance/range + // fields are always miles; charging energy/power fields are always + // kWh/kW. Neither family may be dropped because a vehicle has no + // unit_history row. + if units.IsFixedMileDistanceField(atomic.Field) || + units.IsFixedKiloToBaseField(atomic.Field) { raw, ok := coerceFloat(atomic.Value) if !ok { return codec.Atomic{}, fmt.Errorf("%w: %s value of type %T not coercible to float64", units.ErrUnsupportedField, atomic.Field, atomic.Value) } siValue, err := units.ToSI(atomic.Field, raw, "") if err != nil { - return codec.Atomic{}, fmt.Errorf("normalize: units.ToSI(%s, %v, fixed-mile): %w", atomic.Field, raw, err) + return codec.Atomic{}, fmt.Errorf("normalize: units.ToSI(%s, %v, fixed-wire): %w", atomic.Field, raw, err) } atomic.Value = siValue return atomic, nil @@ -202,8 +197,8 @@ func (p *Pipeline) toSI(ctx context.Context, atomic codec.Atomic, vehicleIntID i return atomic, nil } -// needsConversion reports whether toSI should perform a unit lookup -// + conversion for the field. The two cases that DO need conversion: +// needsConversion reports whether toSI should perform a conversion for the +// field. The cases that DO need conversion: // // - UnitKindDistance / UnitKindTemperature / UnitKindPressure: the // value is in the wire-format unit and must be converted to SI @@ -215,12 +210,15 @@ func (p *Pipeline) toSI(ctx context.Context, atomic codec.Atomic, vehicleIntID i // overloading), but units.ToSI handles them via an internal // speed-conversions table given the active distance unit. // +// - fixed kWh/kW charging fields: UnitKindNone metadata, but Tesla's +// documented wire unit must be scaled to Wh/W without unit history. +// // UnitKindCharge is intentionally a pass-through: SoC scalars are // always emitted in % and units.ToSI returns ErrUnsupportedUnit for // them. The SettingChargeUnit signal is recorded for UI display // preference only. func needsConversion(field string, meta *protomodel.SignalMeta) bool { - if isSpeedField(field) { + if isSpeedField(field) || units.IsFixedKiloToBaseField(field) { return true } if meta == nil { diff --git a/internal/tesla/normalize/normalize_test.go b/internal/tesla/normalize/normalize_test.go index ad411811e4..53b42eb98d 100644 --- a/internal/tesla/normalize/normalize_test.go +++ b/internal/tesla/normalize/normalize_test.go @@ -285,6 +285,61 @@ func TestPipeline_FixedMileNoUnitHistory(t *testing.T) { } } +// TestPipeline_FixedChargingKiloUnits verifies that Tesla's fixed kWh/kW +// charging fields bypass unit history and reach every downstream writer as +// canonical Wh/W values. This is the regression net for charging sessions +// that previously displayed 0.02 kWh for a real 15.09 kWh charge. +func TestPipeline_FixedChargingKiloUnits(t *testing.T) { + t.Parallel() + + const vehicleID int64 = 102 + tNow := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + cases := []struct { + field string + raw float32 + want float64 + }{ + {field: "ACChargingEnergyIn", raw: 15.089165, want: float64(float32(15.089165)) * 1000}, + {field: "DCChargingEnergyIn", raw: 42.5, want: 42500}, + {field: "ACChargingPower", raw: 7.2, want: float64(float32(7.2)) * 1000}, + {field: "DCChargingPower", raw: 250, want: 250000}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.field, func(t *testing.T) { + t.Parallel() + repo := &fakeRepo{} + rt := &fakeRouter{} + p := New(repo, rt, zerolog.Nop()) + + err := p.processAtomics(context.Background(), []codec.Atomic{{ + Field: tc.field, Value: tc.raw, EmittedAt: tNow, VehicleID: "VIN-CHARGE-SI", + }}, vehicleID) + if err != nil { + t.Fatalf("processAtomics returned error: %v", err) + } + + for _, op := range repo.opsCopy() { + if op.kind == "at" { + t.Fatalf("histRepo.At called for fixed-wire field %s", tc.field) + } + } + routes := rt.routesCopy() + if len(routes) != 1 { + t.Fatalf("router received %d routes, want 1: %+v", len(routes), routes) + } + got, ok := routes[0].Value.(float64) + if !ok { + t.Fatalf("routed value type = %T, want float64", routes[0].Value) + } + if math.Abs(got-tc.want) > 1e-6 { + t.Fatalf("%s SI value = %v, want %v", tc.field, got, tc.want) + } + }) + } +} + // TestPipelineHappyPath exercises the three primary dispatch arms in // one payload: // diff --git a/internal/tesla/router/routing.yaml b/internal/tesla/router/routing.yaml index b8f97615cd..39d1e6ed47 100644 --- a/internal/tesla/router/routing.yaml +++ b/internal/tesla/router/routing.yaml @@ -37,6 +37,7 @@ routes: - field: ACChargingPower dest: charging_telemetry column: ac_charging_power_w + also_signal_log: true - field: AutoSeatClimateLeft dest: climate_snapshot column: auto_seat_climate_left @@ -166,6 +167,7 @@ routes: - field: DCChargingPower dest: charging_telemetry column: dc_charging_power_w + also_signal_log: true - field: DCDCEnable dest: signal_log - field: DefrostForPreconditioning diff --git a/internal/tesla/units/conversions.go b/internal/tesla/units/conversions.go index 56090ef313..5f76c382e3 100644 --- a/internal/tesla/units/conversions.go +++ b/internal/tesla/units/conversions.go @@ -174,3 +174,24 @@ var fixedMileDistanceFields = map[string]bool{ func IsFixedMileDistanceField(field string) bool { return fixedMileDistanceFields[field] } + +// fixedKiloToBaseFields lists Tesla charging fields whose wire values use +// kilo-units independent of every Setting*Unit preference. The canonical +// pipeline stores energy in Wh and power in W, so each value must be scaled +// by 1000 before it reaches signal.Store, signal_log, or charging_telemetry. +// +// Source: Tesla Fleet Telemetry "Available Data" documents +// AC/DCChargingEnergyIn in kWh and AC/DCChargingPower in kW: +// https://developer.tesla.com/docs/fleet-api/fleet-telemetry/available-data +var fixedKiloToBaseFields = map[string]bool{ + "ACChargingEnergyIn": true, + "DCChargingEnergyIn": true, + "ACChargingPower": true, + "DCChargingPower": true, +} + +// IsFixedKiloToBaseField reports whether a Tesla field has a fixed kWh/kW +// wire unit that must be converted to Wh/W without consulting unit history. +func IsFixedKiloToBaseField(field string) bool { + return fixedKiloToBaseFields[field] +} diff --git a/internal/tesla/units/units.go b/internal/tesla/units/units.go index 7703df8474..a552403dd6 100644 --- a/internal/tesla/units/units.go +++ b/internal/tesla/units/units.go @@ -12,12 +12,14 @@ import ( // callers (signal.Store writers, signal_log inserts, analytics) consume // SI values exclusively. // -// Canonical SI forms by UnitKind (and the speed-override list): +// Canonical SI forms by UnitKind (and the fixed-wire override lists): // // UnitKindDistance -> meters // UnitKindTemperature -> Celsius // UnitKindPressure -> Pascals // {VehicleSpeed,CruiseSetSpeed} -> meters per second +// {AC,DC}ChargingEnergyIn -> Watt-hours +// {AC,DC}ChargingPower -> Watts // // Errors: // @@ -76,6 +78,13 @@ func ToSI(field string, raw float64, active ActiveUnit) (float64, error) { return raw * 1609.344, nil } + // Tesla reports charging energy in kWh and charging power in kW over + // Fleet Telemetry. These wire units are fixed and do not depend on any + // Setting*Unit signal, so convert directly to canonical Wh/W. + if IsFixedKiloToBaseField(field) { + return raw * 1000.0, nil + } + if meta.UnitKind == protomodel.UnitKindNone { return 0, fmt.Errorf("%w: %q", ErrUnsupportedField, field) } diff --git a/internal/tesla/units/units_test.go b/internal/tesla/units/units_test.go index f64e012566..6b8788d612 100644 --- a/internal/tesla/units/units_test.go +++ b/internal/tesla/units/units_test.go @@ -67,6 +67,14 @@ func TestToSI(t *testing.T) { {name: "SelfDrivingMilesSinceReset/active=mi_treated_as_mi", field: "SelfDrivingMilesSinceReset", raw: 25, active: ActiveUnitMiles, want: 25 * 1609.344, eps: epsTight}, {name: "SelfDrivingMilesSinceReset/active=km_treated_as_mi", field: "SelfDrivingMilesSinceReset", raw: 25, active: ActiveUnitKilometers, want: 25 * 1609.344, eps: epsTight}, + // Fixed charging kilo-units: Tesla emits energy in kWh and power in + // kW regardless of vehicle unit preferences. They bypass unit + // history and land in the canonical pipeline as Wh/W. + {name: "ACChargingEnergyIn/kWh_to_Wh", field: "ACChargingEnergyIn", raw: 15.089164733886719, active: "", want: 15089.164733886719, eps: epsLoose}, + {name: "DCChargingEnergyIn/kWh_to_Wh", field: "DCChargingEnergyIn", raw: 42.5, active: ActiveUnitMiles, want: 42500, eps: epsTight}, + {name: "ACChargingPower/kW_to_W", field: "ACChargingPower", raw: 7.2, active: "", want: 7200, eps: epsTight}, + {name: "DCChargingPower/kW_to_W", field: "DCChargingPower", raw: 250, active: ActiveUnitPSI, want: 250000, eps: epsTight}, + // Temperature fields. {name: "DiHeatsinkTR/F", field: "DiHeatsinkTR", raw: 32, active: ActiveUnitFahrenheit, want: 0, eps: epsTight}, {name: "DiHeatsinkTR/C", field: "DiHeatsinkTR", raw: 25, active: ActiveUnitCelsius, want: 25, eps: epsTight}, diff --git a/internal/tracing/span.go b/internal/tracing/span.go index 749faf421d..4d7742fa3c 100644 --- a/internal/tracing/span.go +++ b/internal/tracing/span.go @@ -67,3 +67,5 @@ func DriveID(id int64) attribute.KeyValue { return attribute.Int64("drive.i func ChargeID(id int64) attribute.KeyValue { return attribute.Int64("charge.id", id) } func TableName(t string) attribute.KeyValue { return attribute.String("db.sql.table", t) } func RowCount(n int) attribute.KeyValue { return attribute.Int("db.row_count", n) } +func GeofenceID(id int64) attribute.KeyValue { return attribute.Int64("geofence.id", id) } +func RateID(id int64) attribute.KeyValue { return attribute.Int64("geofence.rate_id", id) } diff --git a/migrations/000228_geofence_charging_place_pricing.down.sql b/migrations/000228_geofence_charging_place_pricing.down.sql new file mode 100644 index 0000000000..4c7c33dc7d --- /dev/null +++ b/migrations/000228_geofence_charging_place_pricing.down.sql @@ -0,0 +1,38 @@ +-- Down migration for 000228_geofence_charging_place_pricing. +-- +-- Reversing the legacy geofence_electricity_rates_legacy_000004 reconciliation +-- is intentionally NOT attempted: that data now lives in geofence_rates +-- (SI canonical) and dropping this migration's schema does not resurrect the +-- pre-canonical shape or byte-identical legacy rows. This mirrors the +-- project-wide convention that forward-only SI migrations are not +-- round-trip lossless (see migrations/000184_charging_si, 000185_drives_si). +-- +-- btree_gist is NOT dropped here — it was installed by, and is owned by, +-- migrations/000221_small_fleet_operations. + +DROP INDEX IF EXISTS drives_end_geofence_idx; +DROP INDEX IF EXISTS drives_start_geofence_idx; +ALTER TABLE drives + DROP COLUMN IF EXISTS end_geofence_id, + DROP COLUMN IF EXISTS start_geofence_id; + +DROP INDEX IF EXISTS charging_sessions_cost_source_idx; +DROP INDEX IF EXISTS charging_sessions_rate_idx; +DROP INDEX IF EXISTS charging_sessions_geofence_idx; +ALTER TABLE charging_sessions + DROP CONSTRAINT IF EXISTS charging_sessions_cost_source_valid; +ALTER TABLE charging_sessions + DROP COLUMN IF EXISTS cost_source, + DROP COLUMN IF EXISTS rate_id, + DROP COLUMN IF EXISTS geofence_id; + +DROP TABLE IF EXISTS geofence_rates; + +DROP INDEX IF EXISTS geofences_archived_idx; +DROP INDEX IF EXISTS geofences_needs_review_idx; +ALTER TABLE geofences + DROP CONSTRAINT IF EXISTS geofences_origin_valid; +ALTER TABLE geofences + DROP COLUMN IF EXISTS archived_at, + DROP COLUMN IF EXISTS needs_review, + DROP COLUMN IF EXISTS origin; diff --git a/migrations/000228_geofence_charging_place_pricing.up.sql b/migrations/000228_geofence_charging_place_pricing.up.sql new file mode 100644 index 0000000000..a3d00429b5 --- /dev/null +++ b/migrations/000228_geofence_charging_place_pricing.up.sql @@ -0,0 +1,216 @@ +-- Geofence-based charging-place pricing. +-- +-- Geofences become first-class "charging places": they gain discovery +-- provenance (origin/needs_review/archived_at) and an effective-dated, +-- immutable-once-active electricity-rate history (geofence_rates) so a session +-- priced under an old rate keeps that price forever after the rate changes. +-- +-- SI discipline: the canonical rate column is `rate_per_wh` (NOT +-- `rate_per_kwh` / `cost_per_kwh`). The UI converts to currency/kWh only at +-- the render/request boundary (ADR-001/ADR-005). +-- +-- btree_gist was installed by migration 000221_small_fleet_operations; this +-- migration reuses it for the no-overlap exclusion constraint below and does +-- NOT re-drop it in the down migration (owned by 000221). +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- --------------------------------------------------------------------------- +-- 1. Reconcile the abandoned migrations/000004_geofence_electricity_cost +-- precursor. Every database migrates sequentially from 000001, so 000004 +-- always runs and creates the legacy `geofence_electricity_rates` table +-- (no `currency` column, `cost_per_kwh` instead of `rate_per_wh`). +-- migrations/000142_baseline_typed then DROPped+recreated `geofences` +-- (CASCADE), which removes the `cost_per_kwh` column and the FK from +-- `geofence_electricity_rates` onto `geofences`, but does NOT drop +-- `geofence_electricity_rates` itself — CASCADE on the *referenced* table +-- only drops constraints that depend on it, not the dependent table, so +-- the orphaned (FK-less) legacy table survives into every database that +-- has migrated through 000142+. Both this expected path and a +-- hypothetical already-reconciled database (this migration re-run, or a +-- hand-rolled schema with neither artifact) are handled so the migration +-- is idempotent and safe regardless of prior state. +-- --------------------------------------------------------------------------- +DO $$ +DECLARE + legacy_rate_table_exists boolean; + legacy_rate_has_currency boolean; +BEGIN + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'geofence_electricity_rates' + ) INTO legacy_rate_table_exists; + + IF legacy_rate_table_exists THEN + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'geofence_electricity_rates' + AND column_name = 'currency' + ) INTO legacy_rate_has_currency; + + -- Only the pre-canonical shape (no `currency` column, `cost_per_kwh` + -- instead of `rate_per_wh`) needs a data migration. If a later run of + -- this same migration already renamed it to the canonical shape, + -- legacy_rate_has_currency is true and nothing further happens here. + IF NOT legacy_rate_has_currency THEN + ALTER TABLE geofence_electricity_rates + RENAME TO geofence_electricity_rates_legacy_000004; + END IF; + END IF; + + -- `geofences.cost_per_kwh` cannot survive migration 000142's + -- DROP+recreate on any sequentially-migrated database, but a defensive + -- drop documents the intent and protects a hand-rolled/partial schema. + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'geofences' + AND column_name = 'cost_per_kwh' + ) THEN + ALTER TABLE geofences DROP COLUMN cost_per_kwh; + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- 2. Geofence discovery/lifecycle metadata. +-- --------------------------------------------------------------------------- +ALTER TABLE geofences + ADD COLUMN IF NOT EXISTS origin text NOT NULL DEFAULT 'manual', + ADD COLUMN IF NOT EXISTS needs_review boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS archived_at timestamptz; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'geofences_origin_valid' + ) THEN + ALTER TABLE geofences + ADD CONSTRAINT geofences_origin_valid + CHECK (origin IN ('manual', 'charging_discovery')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS geofences_needs_review_idx + ON geofences (id) WHERE needs_review AND archived_at IS NULL; +CREATE INDEX IF NOT EXISTS geofences_archived_idx + ON geofences (archived_at) WHERE archived_at IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 3. Canonical effective-dated electricity rate history. +-- +-- Half-open [effective_from, effective_to) intervals with a GIST +-- exclusion constraint preventing overlapping versions per geofence — +-- same pattern as fleet_charging_policies (migration 000221). The +-- current rate for a geofence is simply the row where effective_to IS +-- NULL (or the row whose interval contains "now"); there is deliberately +-- no separate mutable "current rate" column to keep one source of truth. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS geofence_rates ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + geofence_id bigint NOT NULL REFERENCES geofences(id) ON DELETE CASCADE, + -- NUMERIC(20,10): a currency/kWh rate as small as 0.0001 (=1e-7/Wh) and + -- as large as a hyperinflated-currency rate both fit comfortably, and + -- Postgres NUMERIC arithmetic (not Go float64) is used for cost math. + rate_per_wh NUMERIC(20,10) NOT NULL CHECK (rate_per_wh >= 0 AND rate_per_wh < 1000000), + currency char(3) NOT NULL CHECK (currency ~ '^[A-Z]{3}$'), + effective_from timestamptz NOT NULL, + effective_to timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT geofence_rates_period_valid + CHECK (effective_to IS NULL OR effective_to > effective_from), + CONSTRAINT geofence_rates_no_overlap EXCLUDE USING gist ( + geofence_id WITH =, + tstzrange(effective_from, COALESCE(effective_to, 'infinity'::timestamptz), '[)') WITH && + ) +); + +CREATE INDEX IF NOT EXISTS geofence_rates_geofence_effective_idx + ON geofence_rates (geofence_id, effective_from DESC); +-- Fast lookup of the unbounded interval. It may be future-scheduled, so +-- callers still test effective_from <= their query instant. +CREATE INDEX IF NOT EXISTS geofence_rates_open_idx + ON geofence_rates (geofence_id) WHERE effective_to IS NULL; + +-- Import any usable rows from the legacy pre-canonical shape now that the +-- canonical table exists. cost_per_kwh / 1000 = rate_per_wh (Wh = kWh/1000). +-- The legacy schema had no currency column, so imported rows default to USD +-- — the historical global default before this feature existed. Orphaned +-- rows (geofence_id no longer present, because 000142's CASCADE dropped the +-- old FK without deleting dependent rows) are skipped via the join. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'geofence_electricity_rates_legacy_000004' + ) THEN + INSERT INTO geofence_rates (geofence_id, rate_per_wh, currency, effective_from, effective_to, created_at) + SELECT g.id, + LEAST(GREATEST(legacy.cost_per_kwh / 1000.0, 0), 999999.9999999999), + 'USD', + legacy.effective_from, + legacy.effective_to, + COALESCE(legacy.created_at, legacy.effective_from) + FROM geofence_electricity_rates_legacy_000004 legacy + JOIN geofences g ON g.id = legacy.geofence_id + WHERE legacy.cost_per_kwh IS NOT NULL + ON CONFLICT DO NOTHING; + + DROP TABLE geofence_electricity_rates_legacy_000004; + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- 4. Charging-session provenance. No FK on geofence_id/rate_id: these columns +-- are written from the async (post-completion) leg of the telemetry +-- charge tracker, which must never be blocked by a synchronous FK check +-- against a table it does not otherwise touch on the hot path (mirrors +-- the existing no-FK precedent between charging_telemetry and +-- charging_sessions.id). +-- --------------------------------------------------------------------------- +ALTER TABLE charging_sessions + ADD COLUMN IF NOT EXISTS geofence_id bigint, + ADD COLUMN IF NOT EXISTS rate_id bigint, + ADD COLUMN IF NOT EXISTS cost_source text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'charging_sessions_cost_source_valid' + ) THEN + ALTER TABLE charging_sessions + ADD CONSTRAINT charging_sessions_cost_source_valid + CHECK (cost_source IS NULL OR cost_source IN + ('manual', 'tesla_actual', 'geofence_tariff', 'default_estimate', 'unknown')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS charging_sessions_geofence_idx + ON charging_sessions (geofence_id) WHERE geofence_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS charging_sessions_rate_idx + ON charging_sessions (rate_id) WHERE rate_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS charging_sessions_cost_source_idx + ON charging_sessions (cost_source) WHERE cost_source IS NOT NULL; + +-- Preserve pre-feature costs whose exact origin was not recorded. They are +-- deliberately marked unknown (not geofence-derived), so repricing tools +-- treat them as protected actuals rather than overwriting them. +UPDATE charging_sessions +SET cost_source = 'unknown' +WHERE cost_decimal IS NOT NULL + AND cost_source IS NULL; + +-- --------------------------------------------------------------------------- +-- 5. Drive start/end geofence attribution (match-only; no auto-create). No +-- FK for the same hot-path-adjacency reason as charging_sessions above — +-- drive endpoint resolution also runs off the synchronous ingest path. +-- --------------------------------------------------------------------------- +ALTER TABLE drives + ADD COLUMN IF NOT EXISTS start_geofence_id bigint, + ADD COLUMN IF NOT EXISTS end_geofence_id bigint; + +CREATE INDEX IF NOT EXISTS drives_start_geofence_idx + ON drives (start_geofence_id) WHERE start_geofence_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS drives_end_geofence_idx + ON drives (end_geofence_id) WHERE end_geofence_id IS NOT NULL; diff --git a/migrations/000229_fix_charging_energy_power_si.down.sql b/migrations/000229_fix_charging_energy_power_si.down.sql new file mode 100644 index 0000000000..cd6aef686d --- /dev/null +++ b/migrations/000229_fix_charging_energy_power_si.down.sql @@ -0,0 +1,2 @@ +-- Intentional no-op: this is a data repair. Dividing all values on rollback +-- would corrupt rows written in canonical Wh/W after the up migration ran. diff --git a/migrations/000229_fix_charging_energy_power_si.up.sql b/migrations/000229_fix_charging_energy_power_si.up.sql new file mode 100644 index 0000000000..c09200308a --- /dev/null +++ b/migrations/000229_fix_charging_energy_power_si.up.sql @@ -0,0 +1,139 @@ +-- Repair Tesla Fleet Telemetry charging values that were persisted with +-- their fixed wire units (kWh/kW) in SI-canonical Wh/W columns. +-- +-- The normalize pipeline now scales AC/DCChargingEnergyIn and +-- AC/DCChargingPower by 1000 before routing. This migration performs the +-- equivalent one-time correction for rows written before that fix, restores +-- missing power history in signal_log, rebuilds session peak/average power, +-- and recalculates only rate-derived costs. Manual, Tesla-actual, and unknown +-- provenance costs remain untouched. + +-- Attribute old hot-table rows before aggregating them into session metrics. +UPDATE charging_telemetry AS telemetry + SET session_id = session.id + FROM charging_sessions AS session + WHERE telemetry.session_id IS NULL + AND session.ended_at IS NOT NULL + AND telemetry.vehicle_id = session.vehicle_id + AND telemetry.ts >= session.started_at + AND telemetry.ts <= session.ended_at; + +-- Hot telemetry columns were named Wh/W but held Tesla's kWh/kW values. +UPDATE charging_telemetry + SET ac_charging_energy_in_wh = + CASE WHEN ac_charging_energy_in_wh IS NULL + THEN NULL ELSE ac_charging_energy_in_wh * 1000.0 END, + dc_charging_energy_in_wh = + CASE WHEN dc_charging_energy_in_wh IS NULL + THEN NULL ELSE dc_charging_energy_in_wh * 1000.0 END, + ac_charging_power_w = + CASE WHEN ac_charging_power_w IS NULL + THEN NULL ELSE ac_charging_power_w * 1000.0 END, + dc_charging_power_w = + CASE WHEN dc_charging_power_w IS NULL + THEN NULL ELSE dc_charging_power_w * 1000.0 END + WHERE ac_charging_energy_in_wh IS NOT NULL + OR dc_charging_energy_in_wh IS NOT NULL + OR ac_charging_power_w IS NOT NULL + OR dc_charging_power_w IS NOT NULL; + +-- Energy was already dual-written to signal_log; power may exist there on a +-- database that briefly ran an intermediate route. Correct either case. +UPDATE signal_log + SET float_value = float_value * 1000.0 + WHERE field IN ( + 'ACChargingEnergyIn', + 'DCChargingEnergyIn', + 'ACChargingPower', + 'DCChargingPower' + ) + AND float_value IS NOT NULL; + +-- Power was not historically dual-written. Backfill it from the corrected +-- hot table so charge charts and completion aggregates have a durable source. +INSERT INTO signal_log ( + vehicle_id, + ts, + field, + value_kind, + float_value +) +SELECT vehicle_id, ts, 'ACChargingPower', 5, ac_charging_power_w + FROM charging_telemetry + WHERE ac_charging_power_w IS NOT NULL +ON CONFLICT (vehicle_id, ts, field) DO NOTHING; + +INSERT INTO signal_log ( + vehicle_id, + ts, + field, + value_kind, + float_value +) +SELECT vehicle_id, ts, 'DCChargingPower', 5, dc_charging_power_w + FROM charging_telemetry + WHERE dc_charging_power_w IS NOT NULL +ON CONFLICT (vehicle_id, ts, field) DO NOTHING; + +-- Direct Fleet Telemetry totals were stored as kWh. Battery-delta fallback +-- estimates start at 750 Wh, so the <500 guard distinguishes the malformed +-- direct totals while the telemetry existence check excludes imports and the +-- API-polling path, which already wrote Wh correctly. +UPDATE charging_sessions AS session + SET total_energy_added_wh = session.total_energy_added_wh * 1000.0 + WHERE session.total_energy_added_wh > 0 + AND session.total_energy_added_wh < 500.0 + AND EXISTS ( + SELECT 1 + FROM charging_telemetry AS telemetry + WHERE telemetry.session_id = session.id + ); + +-- Rebuild peak/average power from corrected W telemetry. DC takes precedence +-- when a session contains DC samples, matching SignalLogReader semantics. +WITH power_rollup AS ( + SELECT + session_id, + CASE + WHEN COUNT(dc_charging_power_w) + FILTER (WHERE dc_charging_power_w > 0) > 0 + THEN MAX(dc_charging_power_w) + FILTER (WHERE dc_charging_power_w > 0) + ELSE MAX(ac_charging_power_w) + FILTER (WHERE ac_charging_power_w > 0) + END AS peak_power_w, + CASE + WHEN COUNT(dc_charging_power_w) + FILTER (WHERE dc_charging_power_w > 0) > 0 + THEN AVG(dc_charging_power_w) + FILTER (WHERE dc_charging_power_w > 0) + ELSE AVG(ac_charging_power_w) + FILTER (WHERE ac_charging_power_w > 0) + END AS avg_power_w + FROM charging_telemetry + WHERE session_id IS NOT NULL + GROUP BY session_id +) +UPDATE charging_sessions AS session + SET peak_power_w = rollup.peak_power_w, + avg_power_w = rollup.avg_power_w + FROM power_rollup AS rollup + WHERE session.id = rollup.session_id + AND rollup.peak_power_w IS NOT NULL; + +-- Tariff/default estimates are deterministic derivatives of energy and may be +-- safely recomputed. Protected actual/manual/unknown costs are never changed. +UPDATE charging_sessions AS session + SET cost_decimal = ROUND( + session.total_energy_added_wh::numeric * rate.rate_per_wh, + 6 + ), + cost_currency = rate.currency + FROM geofence_rates AS rate + WHERE session.rate_id = rate.id + AND session.cost_source IN ('geofence_tariff', 'default_estimate') + AND session.total_energy_added_wh IS NOT NULL; + +-- These regular materialized views cache charging_sessions values. +REFRESH MATERIALIZED VIEW cagg_charging_summary; +REFRESH MATERIALIZED VIEW mv_energy_daily; diff --git a/slo/catalog.yaml b/slo/catalog.yaml index cd82c490bc..2a62b1b588 100644 --- a/slo/catalog.yaml +++ b/slo/catalog.yaml @@ -34,6 +34,16 @@ slos: owner: platform tags: [http, latency] + - name: vehicle_management_availability + description: "Tesla Vehicle Management metadata and enterprise operations must complete without server errors." + sli: + good_events: "sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/(tesla/vehicle-pricing|vehicles/\\\\{vehicleID\\\\}/((options|specs|subscriptions|upgrades|warranty|enterprise-roles)(|/refresh)|enterprise-payer))\",status_class!=\"5xx\"}[5m]))" + valid_events: "sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/(tesla/vehicle-pricing|vehicles/\\\\{vehicleID\\\\}/((options|specs|subscriptions|upgrades|warranty|enterprise-roles)(|/refresh)|enterprise-payer))\"}[5m]))" + objective: 99.0 + window: 30d + owner: platform + tags: [http, vehicle-management, upstream] + - name: service_intelligence_availability description: "Recall and service-intelligence requests must return without a server or upstream-gateway failure." sli: @@ -213,3 +223,23 @@ slos: window: 30d owner: analytics tags: [http, privacy, benchmarks] + + - name: geofence_pricing_read_availability + description: "Charging-place rate history, preview, and activity reads must avoid server errors." + sli: + good_events: "sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\",status_class!=\"5xx\"}[5m]))" + valid_events: "sum(rate(teslasync_red_http_requests_total{method=\"GET\",route=~\"/api/v1/geofences/(needs-review|rates/current|\\\\{geofenceID\\\\}/(rates(|/\\\\{rateID\\\\}/preview)|charging-summary|charging-activity))\"}[5m]))" + objective: 99.5 + window: 30d + owner: platform + tags: [http, geofences, pricing] + + - name: geofence_pricing_write_availability + description: "Charging-place rate creation, apply/backfill, and lifecycle writes must avoid server errors." + sli: + good_events: "sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\",status_class!=\"5xx\"}[5m]))" + valid_events: "sum(rate(teslasync_red_http_requests_total{method=~\"POST|DELETE\",route=~\"/api/v1/geofences/\\\\{geofenceID\\\\}/(archive|unarchive|reviewed|rates(|/\\\\{rateID\\\\}(|/apply)))\"}[5m]))" + objective: 99.0 + window: 30d + owner: platform + tags: [http, geofences, pricing, writes] diff --git a/web/src/App.tsx b/web/src/App.tsx index 43c5ae4c93..5ffed0554a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,6 +23,7 @@ const GlancePage = lazy(() => import('./features/dashboard/pages/GlancePage')) const Vehicles = lazy(() => import('./features/vehicles/pages/VehicleListPage')) const VehicleDetail = lazy(() => import('./features/vehicles/pages/VehicleDetailPage')) const VehicleAccess = lazy(() => import('./features/vehicles/pages/VehicleAccessPage')) +const VehicleManagement = lazy(() => import('./features/vehicles/pages/VehicleManagementPage')) const DigitalTwin = lazy(() => import('./features/vehicles/pages/DigitalTwinPage')) const TimeMachine = lazy(() => import('./features/vehicles/pages/TimeMachinePage')) @@ -476,6 +477,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/__tests__/lazyRoutes.list.ts b/web/src/__tests__/lazyRoutes.list.ts index c0ba72f89e..1a96c0be25 100644 --- a/web/src/__tests__/lazyRoutes.list.ts +++ b/web/src/__tests__/lazyRoutes.list.ts @@ -30,6 +30,7 @@ export const LAZY_ROUTE_IMPORTS: Array<{ { name: 'Vehicles', load: () => import('../features/vehicles/pages/VehicleListPage') }, { name: 'VehicleDetail', load: () => import('../features/vehicles/pages/VehicleDetailPage') }, { name: 'VehicleAccess', load: () => import('../features/vehicles/pages/VehicleAccessPage') }, + { name: 'VehicleManagement', load: () => import('../features/vehicles/pages/VehicleManagementPage') }, { name: 'DigitalTwin', load: () => import('../features/vehicles/pages/DigitalTwinPage') }, // Charging diff --git a/web/src/api/hooks/useCommands.ts b/web/src/api/hooks/useCommands.ts index f4182df634..255f6ca1e8 100644 --- a/web/src/api/hooks/useCommands.ts +++ b/web/src/api/hooks/useCommands.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { request } from '../client'; -import { STALE_TIMES } from '@/lib/constants'; +import { INTERVALS, STALE_TIMES } from '@/lib/constants'; export interface CommandLogEntry { id: number; @@ -64,5 +64,6 @@ export function useCommandLatest(vehicleId: string | number | undefined) { }, enabled: !!vehicleId, staleTime: STALE_TIMES.MODERATE, + refetchInterval: INTERVALS.STANDARD, }); } diff --git a/web/src/api/hooks/useLocations.test.tsx b/web/src/api/hooks/useLocations.test.tsx index 7873de5e01..e44e2d8f08 100644 --- a/web/src/api/hooks/useLocations.test.tsx +++ b/web/src/api/hooks/useLocations.test.tsx @@ -46,11 +46,34 @@ vi.mock('./_toastHelpers', () => ({ import { request } from '@/api/client'; import { __flushQueryBroadcastForTests } from '@/lib/queryBroadcast'; import type { Location, Geofence } from '@/types/location'; +import type { + Geofence as ApiGeofence, + GeofenceRate, + GeofenceRateImpactPreview, + GeofenceRateApplyResult, + GeofenceChargingSummary, + GeofenceChargingActivity, +} from '@/api/types'; import { locationKeys, useLocations, useGeofences, + useGeofencesFull, useBulkGeofencesDelete, + useGeofenceNeedsReview, + useGeofenceCurrentRates, + useArchiveGeofence, + useUnarchiveGeofence, + useMarkGeofenceReviewed, + useRenameGeofence, + useUpdateGeofenceCategory, + useGeofenceRates, + useCreateGeofenceRate, + useDeleteGeofenceRate, + useGeofenceRatePreview, + useApplyGeofenceRate, + useGeofenceChargingSummary, + useGeofenceChargingActivity, type GeofenceBulkResult, } from './useLocations'; @@ -92,12 +115,45 @@ function makeGeofence(overrides: Partial = {}): Geofence { alertOnEntry: true, alertOnExit: false, enabled: true, - costPerKwh: null, + origin: 'manual', + needsReview: false, createdAt: '2026-06-01T00:00:00Z', ...overrides, }; } +function makeApiGeofence(overrides: Partial = {}): ApiGeofence { + return { + id: 1, + name: 'Home', + polygon_wkt: 'POLYGON((-122.2 37.5, -122.2 37.5, -122.2 37.5, -122.2 37.5))', + enabled: true, + alert_on_entry: true, + alert_on_exit: false, + origin: 'manual', + needs_review: false, + created_at: '2026-06-01T00:00:00Z', + updated_at: '2026-06-01T00:00:00Z', + latitude: 37.5, + longitude: -122.2, + radius: 20, + ...overrides, + }; +} + +function makeGeofenceRate(overrides: Partial = {}): GeofenceRate { + return { + id: 10, + geofence_id: 1, + rate_per_wh: 0.0001, + currency: 'USD', + effective_from: '2026-01-01T00:00:00Z', + effective_to: null, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + beforeEach(() => { mockedRequest.mockReset(); successToast.mockReset(); @@ -305,3 +361,506 @@ describe('useBulkGeofencesDelete', () => { expect(invalidate).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Charging Places — geofence-based charging-place pricing feature +// --------------------------------------------------------------------------- + +describe('useGeofencesFull', () => { + it('GETs /geofences (shares the useGeofences query key) and returns the canonical snake_case shape', async () => { + const payload = [makeApiGeofence({ id: 1 }), makeApiGeofence({ id: 2, name: 'Office' })]; + mockedRequest.mockResolvedValueOnce(payload); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofencesFull(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(payload); + expect(result.current.data?.[0].needs_review).toBe(false); + + const [url] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences'); + expect(url).not.toContain('/api/v1'); + }); + + it('coerces a non-array response to [] via the safeArray select guard', async () => { + mockedRequest.mockResolvedValueOnce(null); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofencesFull(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); + + it('requests archived rows explicitly when includeArchived is true', async () => { + mockedRequest.mockResolvedValueOnce([]); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofencesFull(true), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences?include_archived=true'); + }); +}); + +describe('useGeofenceNeedsReview', () => { + it('GETs /geofences/needs-review and returns the provisional-place queue', async () => { + const payload = [ + makeApiGeofence({ id: 5, origin: 'charging_discovery', needs_review: true, name: '' }), + ]; + mockedRequest.mockResolvedValueOnce(payload); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceNeedsReview(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(payload); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences/needs-review'); + }); + + it('coerces a non-array response to []', async () => { + mockedRequest.mockResolvedValueOnce(null); + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceNeedsReview(), { wrapper: Wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); + +describe('useGeofenceCurrentRates', () => { + it('GETs /geofences/rates/current — the bulk current-rate lookup for every geofence', async () => { + const payload = [makeGeofenceRate({ geofence_id: 1 }), makeGeofenceRate({ geofence_id: 2, id: 11 })]; + mockedRequest.mockResolvedValueOnce(payload); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceCurrentRates(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(payload); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences/rates/current'); + }); +}); + +describe('useRenameGeofence', () => { + it('PUTs the new name and invalidates place lifecycle queries', async () => { + mockedRequest.mockResolvedValueOnce(makeApiGeofence({ id: 7, name: 'Office Charger' })); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useRenameGeofence(), { wrapper: Wrapper }); + + await result.current.mutateAsync({ geofenceId: 7, name: 'Office Charger' }); + + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/7'); + expect(opts.method).toBe('PUT'); + expect(JSON.parse(opts.body as string)).toEqual({ name: 'Office Charger' }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofences }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofencesNeedsReview }); + expect(successToast).toHaveBeenCalledWith( + 'toast.geofence.rename.success', + 'Place renamed', + ); + }); +}); + +describe('useUpdateGeofenceCategory', () => { + it('PUTs the category and invalidates place lifecycle queries', async () => { + mockedRequest.mockResolvedValueOnce(makeApiGeofence({ id: 7, category: 'work' })); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useUpdateGeofenceCategory(), { + wrapper: Wrapper, + }); + + await result.current.mutateAsync({ geofenceId: 7, category: 'work' }); + + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/7'); + expect(opts.method).toBe('PUT'); + expect(JSON.parse(opts.body as string)).toEqual({ category: 'work' }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofences }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofencesNeedsReview }); + expect(successToast).toHaveBeenCalledWith( + 'toast.geofence.category.success', + 'Category updated', + ); + }); +}); + +describe('useArchiveGeofence', () => { + it('POSTs /geofences/{id}/archive, invalidates the lifecycle caches, and toasts success', async () => { + mockedRequest.mockResolvedValueOnce(makeApiGeofence({ id: 3, archived_at: '2026-08-01T00:00:00Z' })); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useArchiveGeofence(), { wrapper: Wrapper }); + + await result.current.mutateAsync(3); + + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/3/archive'); + expect(opts.method).toBe('POST'); + + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofences }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofencesNeedsReview }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofenceRatesCurrent }); + expect(successToast).toHaveBeenCalledWith('toast.geofence.archive.success', 'Place archived'); + }); + + it('toasts the error and does not invalidate when the archive POST fails', async () => { + mockedRequest.mockRejectedValueOnce(new Error('archive boom')); + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useArchiveGeofence(), { wrapper: Wrapper }); + + await expect(result.current.mutateAsync(3)).rejects.toThrow('archive boom'); + expect(errorToast).toHaveBeenCalledWith(expect.any(Error), 'toast.geofence.archive.error', 'Failed to archive place'); + expect(invalidate).not.toHaveBeenCalled(); + }); +}); + +describe('useUnarchiveGeofence', () => { + it('POSTs /geofences/{id}/unarchive, invalidates the lifecycle caches, and toasts success', async () => { + mockedRequest.mockResolvedValueOnce(makeApiGeofence({ id: 3, archived_at: null })); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useUnarchiveGeofence(), { wrapper: Wrapper }); + + await result.current.mutateAsync(3); + + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/3/unarchive'); + expect(opts.method).toBe('POST'); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofences }); + expect(successToast).toHaveBeenCalledWith('toast.geofence.unarchive.success', 'Place restored'); + }); +}); + +describe('useMarkGeofenceReviewed', () => { + it('POSTs /geofences/{id}/reviewed, invalidates the needs-review queue, and toasts success', async () => { + mockedRequest.mockResolvedValueOnce(makeApiGeofence({ id: 3, needs_review: false })); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useMarkGeofenceReviewed(), { wrapper: Wrapper }); + + await result.current.mutateAsync(3); + + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/3/reviewed'); + expect(opts.method).toBe('POST'); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofencesNeedsReview }); + expect(successToast).toHaveBeenCalledWith('toast.geofence.reviewed.success', 'Marked reviewed'); + }); +}); + +describe('useGeofenceRates', () => { + it('GETs /geofences/{id}/rates and returns every time-versioned row', async () => { + const payload = [makeGeofenceRate({ id: 1 }), makeGeofenceRate({ id: 2, effective_from: '2025-01-01T00:00:00Z' })]; + mockedRequest.mockResolvedValueOnce(payload); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceRates(7), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(payload); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences/7/rates'); + }); + + it('is disabled (no fetch) when geofenceId is undefined', async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceRates(undefined), { wrapper: Wrapper }); + + await new Promise((r) => setTimeout(r, 10)); + expect(mockedRequest).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe('idle'); + }); +}); + +describe('useCreateGeofenceRate', () => { + it('POSTs the canonical rate_per_wh body to /geofences/{id}/rates and invalidates rates + current + geofences', async () => { + const created = makeGeofenceRate({ id: 99 }); + mockedRequest.mockResolvedValueOnce(created); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useCreateGeofenceRate(), { wrapper: Wrapper }); + + const resolved = await result.current.mutateAsync({ + geofenceId: 7, + rate_per_wh: 0.00012, + currency: 'USD', + effective_from: '2026-08-27T00:00:00.000Z', + }); + + expect(resolved).toEqual(created); + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/7/rates'); + expect(opts.method).toBe('POST'); + // The body must carry rate_per_wh (SI canonical), never a *_kwh field, + // and must NOT include geofenceId (a path param, not a body field). + const body = JSON.parse(opts.body as string); + expect(body).toEqual({ + rate_per_wh: 0.00012, + currency: 'USD', + effective_from: '2026-08-27T00:00:00.000Z', + }); + expect(body).not.toHaveProperty('geofenceId'); + expect(Object.keys(body).some((k) => /kwh/i.test(k))).toBe(false); + + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofenceRates(7) }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofenceRatesCurrent }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofences }); + expect(successToast).toHaveBeenCalledWith('toast.geofenceRate.create.success', 'Rate saved'); + }); + + it('includes effective_to in the body only when provided', async () => { + mockedRequest.mockResolvedValueOnce(makeGeofenceRate()); + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useCreateGeofenceRate(), { wrapper: Wrapper }); + + await result.current.mutateAsync({ + geofenceId: 7, + rate_per_wh: 0.0001, + currency: 'USD', + effective_from: '2025-01-01T00:00:00.000Z', + effective_to: '2026-08-27T00:00:00.000Z', + }); + + const body = JSON.parse(mockedRequest.mock.calls[0][1].body as string); + expect(body.effective_to).toBe('2026-08-27T00:00:00.000Z'); + }); + + it('toasts the error and does not invalidate when the create POST fails', async () => { + mockedRequest.mockRejectedValueOnce(new Error('create rate boom')); + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useCreateGeofenceRate(), { wrapper: Wrapper }); + + await expect( + result.current.mutateAsync({ + geofenceId: 7, + rate_per_wh: 0.0001, + currency: 'USD', + effective_from: '2026-01-01T00:00:00.000Z', + }), + ).rejects.toThrow('create rate boom'); + + expect(errorToast).toHaveBeenCalledWith(expect.any(Error), 'toast.geofenceRate.create.error', 'Failed to save rate'); + expect(invalidate).not.toHaveBeenCalled(); + }); +}); + +describe('useDeleteGeofenceRate', () => { + it('DELETEs /geofences/{id}/rates/{rateId}, invalidates rates + current, and toasts success', async () => { + mockedRequest.mockResolvedValueOnce(undefined); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useDeleteGeofenceRate(), { wrapper: Wrapper }); + + await result.current.mutateAsync({ geofenceId: 7, rateId: 99 }); + + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/7/rates/99'); + expect(opts.method).toBe('DELETE'); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofenceRates(7) }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofenceRatesCurrent }); + expect(successToast).toHaveBeenCalledWith( + 'toast.geofenceRate.delete.success', + 'Scheduled rate cancelled', + ); + }); +}); + +describe('useGeofenceRatePreview', () => { + it('GETs the preview endpoint with no query string when from/to are omitted', async () => { + const preview: GeofenceRateImpactPreview = { + matched_sessions: 10, + eligible_sessions: 6, + protected_sessions: 4, + total_energy_wh: 50_000, + estimated_cost_decimal: 5.5, + currency: 'USD', + }; + mockedRequest.mockResolvedValueOnce(preview); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceRatePreview(7, 99), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(preview); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences/7/rates/99/preview'); + }); + + it('appends from/to as query params when narrowing the window', async () => { + mockedRequest.mockResolvedValueOnce({ + matched_sessions: 1, + eligible_sessions: 1, + protected_sessions: 0, + total_energy_wh: 1000, + estimated_cost_decimal: 0.1, + currency: 'USD', + }); + + const { Wrapper } = makeWrapper(); + renderHook( + () => useGeofenceRatePreview(7, 99, { from: '2026-01-01T00:00:00.000Z', to: '2026-02-01T00:00:00.000Z' }), + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(mockedRequest).toHaveBeenCalledTimes(1)); + const url = mockedRequest.mock.calls[0][0] as string; + expect(url).toBe('/geofences/7/rates/99/preview?from=2026-01-01T00%3A00%3A00.000Z&to=2026-02-01T00%3A00%3A00.000Z'); + }); + + it('is disabled until both geofenceId and rateId are known', async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceRatePreview(7, undefined), { wrapper: Wrapper }); + + await new Promise((r) => setTimeout(r, 10)); + expect(mockedRequest).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe('idle'); + }); +}); + +describe('useApplyGeofenceRate', () => { + it('POSTs the apply endpoint and invalidates this place\'s summary/activity AND the global charging-sessions cache', async () => { + const applyResult: GeofenceRateApplyResult = { + geofence_id: 7, + rate_id: 99, + matched_sessions: 10, + priced_sessions: 6, + skipped_sessions: 4, + total_energy_wh: 55_000, + total_cost_decimal: 5.5, + currency: 'USD', + }; + mockedRequest.mockResolvedValueOnce(applyResult); + + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useApplyGeofenceRate(), { wrapper: Wrapper }); + + const resolved = await result.current.mutateAsync({ geofenceId: 7, rateId: 99 }); + + expect(resolved).toEqual(applyResult); + const [url, opts] = mockedRequest.mock.calls[0]; + expect(url).toBe('/geofences/7/rates/99/apply'); + expect(opts.method).toBe('POST'); + + expect(invalidate).toHaveBeenCalledWith({ + queryKey: ['geofences', 7, 'rates', 99, 'preview'], + }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: locationKeys.geofenceChargingSummary(7) }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['geofences', 7, 'charging-activity'] }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['charging-sessions'] }); + expect(successToast).toHaveBeenCalledWith('toast.geofenceRate.apply.success', 'Rate applied to matching sessions'); + }); + + it('appends from/to as query params when narrowing the applied interval', async () => { + mockedRequest.mockResolvedValueOnce({ + priced_sessions: 1, + skipped_sessions: 0, + total_cost_decimal: 1, + currency: 'USD', + }); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useApplyGeofenceRate(), { wrapper: Wrapper }); + + await result.current.mutateAsync({ + geofenceId: 7, + rateId: 99, + from: '2026-01-01T00:00:00.000Z', + to: '2026-02-01T00:00:00.000Z', + }); + + const url = mockedRequest.mock.calls[0][0] as string; + expect(url).toBe('/geofences/7/rates/99/apply?from=2026-01-01T00%3A00%3A00.000Z&to=2026-02-01T00%3A00%3A00.000Z'); + }); + + it('toasts the error and does not invalidate when the apply POST fails', async () => { + mockedRequest.mockRejectedValueOnce(new Error('apply boom')); + const { Wrapper, qc } = makeWrapper(); + const invalidate = vi.spyOn(qc, 'invalidateQueries'); + const { result } = renderHook(() => useApplyGeofenceRate(), { wrapper: Wrapper }); + + await expect(result.current.mutateAsync({ geofenceId: 7, rateId: 99 })).rejects.toThrow('apply boom'); + expect(errorToast).toHaveBeenCalledWith(expect.any(Error), 'toast.geofenceRate.apply.error', 'Failed to apply rate'); + expect(invalidate).not.toHaveBeenCalled(); + }); +}); + +describe('useGeofenceChargingSummary', () => { + it('GETs /geofences/{id}/charging-summary and returns the per-currency rows', async () => { + const payload: GeofenceChargingSummary[] = [ + { currency: 'USD', session_count: 5, total_energy_wh: 40_000, total_cost_decimal: 4.4 }, + { currency: 'EUR', session_count: 2, total_energy_wh: 10_000, total_cost_decimal: 1.1 }, + ]; + mockedRequest.mockResolvedValueOnce(payload); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceChargingSummary(7), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // Multiple currencies must stay as separate rows, never summed together. + expect(result.current.data).toEqual(payload); + expect(result.current.data).toHaveLength(2); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences/7/charging-summary'); + }); + + it('is disabled (no fetch) when geofenceId is undefined', async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceChargingSummary(undefined), { wrapper: Wrapper }); + await new Promise((r) => setTimeout(r, 10)); + expect(mockedRequest).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe('idle'); + }); +}); + +describe('useGeofenceChargingActivity', () => { + it('GETs /geofences/{id}/charging-activity with default limit=50 & offset=0', async () => { + const payload: GeofenceChargingActivity[] = [ + { + session_id: 1, + started_at: '2026-08-01T00:00:00Z', + ended_at: '2026-08-01T01:00:00Z', + energy_wh: 10_000, + cost_decimal: 1.2, + cost_currency: 'USD', + cost_source: 'geofence_tariff', + rate_id: 99, + }, + ]; + mockedRequest.mockResolvedValueOnce(payload); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceChargingActivity(7), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(payload); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences/7/charging-activity?limit=50&offset=0'); + }); + + it('threads a custom limit/offset through to the query string', async () => { + mockedRequest.mockResolvedValueOnce([]); + const { Wrapper } = makeWrapper(); + renderHook(() => useGeofenceChargingActivity(7, 25, 25), { wrapper: Wrapper }); + + await waitFor(() => expect(mockedRequest).toHaveBeenCalledTimes(1)); + expect(mockedRequest.mock.calls[0][0]).toBe('/geofences/7/charging-activity?limit=25&offset=25'); + }); + + it('is disabled (no fetch) when geofenceId is undefined', async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useGeofenceChargingActivity(undefined), { wrapper: Wrapper }); + await new Promise((r) => setTimeout(r, 10)); + expect(mockedRequest).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe('idle'); + }); +}); diff --git a/web/src/api/hooks/useLocations.ts b/web/src/api/hooks/useLocations.ts index 2877ced922..64565d328a 100644 --- a/web/src/api/hooks/useLocations.ts +++ b/web/src/api/hooks/useLocations.ts @@ -3,11 +3,36 @@ import { request } from '../client'; import { safeArray } from '@/lib/safeArray'; import { useMutationToast } from './_toastHelpers'; import { invalidateAndBroadcast } from '@/lib/queryBroadcast'; +import { chargingKeys } from './useCharging'; import type { Location, Geofence } from '@/types/location'; +// Canonical snake_case Charging Places DTOs (@/api/types). Aliased to avoid +// colliding with the legacy camelCase `Geofence` above — same split, and +// same reasoning, as `ChargingSession`/`ApiChargingSession` in useCharging.ts. +import type { + Geofence as ApiGeofence, + GeofenceRate, + GeofenceRateCreateRequest, + GeofenceRateImpactPreview, + GeofenceRateApplyResult, + GeofenceChargingSummary, + GeofenceChargingActivity, + GeofenceCategory, +} from '../types'; export const locationKeys = { all: (vehicleId?: string) => ['locations', vehicleId ?? 'all'] as const, geofences: ['geofences'] as const, + geofencesFull: (includeArchived: boolean) => + ['geofences', 'full', includeArchived ? 'with-archived' : 'active'] as const, + geofencesNeedsReview: ['geofences', 'needs-review'] as const, + geofenceRatesCurrent: ['geofences', 'rates', 'current'] as const, + geofenceRates: (geofenceId: number) => ['geofences', geofenceId, 'rates'] as const, + geofenceRatePreview: (geofenceId: number, rateId: number, from?: string, to?: string) => + ['geofences', geofenceId, 'rates', rateId, 'preview', from ?? '', to ?? ''] as const, + geofenceChargingSummary: (geofenceId: number) => + ['geofences', geofenceId, 'charging-summary'] as const, + geofenceChargingActivity: (geofenceId: number, limit: number, offset: number) => + ['geofences', geofenceId, 'charging-activity', limit, offset] as const, }; /** @@ -38,16 +63,22 @@ export function useLocations(vehicleId?: string) { /** * useGeofences — GET /geofences * - * @deprecated The legacy `Geofence` type in `@/types/location` declares - * `enabled`, `alertOnEntry`, `alertOnExit`, `costPerKwh` — none of which + * The legacy camelCase `Geofence` type in `@/types/location` now matches * the post-migration backend `models.Geofence` (internal/models/system.go) - * persists or emits. Backend MarshalJSON augments the response with - * `latitude`, `longitude`, `radius` (centroid + max-vertex meters) only. - * Pages reading the missing fields render fall-through defaults - * (Inactive / None / blank) — pre-existing UI/contract drift. - * Reconciliation requires a backend migration to add the missing columns - * plus a coordinated update to internal/models/system.go, - * internal/api/geofence_handler.go, and the frontend type+pages. + * field-for-field: `enabled`, `alertOnEntry`, `alertOnExit`, `origin`, + * `needsReview`, optional `category`/`archivedAt`. Backend MarshalJSON + * augments the response with `latitude`, `longitude`, `radius` (centroid + + * max-vertex meters, derived from the stored polygon) alongside the raw + * `polygon_wkt`. The legacy type intentionally omits `polygon_wkt` and + * `updated_at` since no consumer of this hook needs them — reach for the + * canonical snake_case `Geofence` in `@/api/types` (used by the newer + * Charging Places hooks below) if a future page needs the full wire shape. + * + * The one field this backend model never persisted or emitted — + * `cost_per_kwh` — has been fully removed from the frontend type too. A + * geofence's electricity rate is a first-class, time-versioned resource + * (`GeofenceRate` / `rate_per_wh`) fetched via the dedicated Charging + * Places hooks below, never a field on the geofence itself. */ export function useGeofences() { return useQuery({ @@ -57,6 +88,28 @@ export function useGeofences() { }); } +/** + * useGeofencesFull — GET /geofences (canonical snake_case shape) + * + * Uses a dedicated cache key because the Charging Places workspace can + * explicitly request archived rows while the legacy geofence-management + * surface must continue receiving active rows only. The workspace also + * polls so a newly discovered charging place appears without a page reload. + */ +export function useGeofencesFull(includeArchived = false) { + return useQuery({ + queryKey: locationKeys.geofencesFull(includeArchived), + queryFn: ({ signal }) => + request( + includeArchived ? '/geofences?include_archived=true' : '/geofences', + { signal }, + ), + select: safeArray, + staleTime: 15_000, + refetchInterval: 30_000, + }); +} + export interface GeofenceBulkResult { deleted: number; failed: { id: number; reason: string }[]; @@ -84,3 +137,357 @@ export function useBulkGeofencesDelete() { error(err, 'toast.geofence.bulkDelete.error', 'Failed to delete geofences'), }); } + +// ============================================================================= +// Charging Places — geofence-based charging-place pricing feature +// (migration 000228_geofence_charging_place_pricing; +// internal/api/geofence/rate_handler.go is the wire-contract source of +// truth). All rate amounts on the wire are `rate_per_wh` — SI-canonical +// currency-per-watt-hour, never per-kWh. Convert to currency/kWh only at +// the render/request boundary (see +// features/maps/components/charging-places/helpers.ts). +// ============================================================================= + +/** + * useGeofenceNeedsReview — GET /geofences/needs-review + * + * The auto-discovered "Needs Setup" queue: provisional charging-place + * geofences (`origin: 'charging_discovery'`, `needs_review: true`) awaiting + * a human to confirm/edit their name, category, or location. + */ +export function useGeofenceNeedsReview() { + return useQuery({ + queryKey: locationKeys.geofencesNeedsReview, + queryFn: ({ signal }) => request('/geofences/needs-review', { signal }), + select: safeArray, + staleTime: 15_000, + refetchInterval: 30_000, + }); +} + +/** + * useGeofenceCurrentRates — GET /geofences/rates/current + * + * The currently-active rate (if any) for every geofence in one round trip + * — powers the Charging Places list's rate column without a per-row N+1 + * lookup. A geofence with no row here has never had a rate configured. + */ +export function useGeofenceCurrentRates() { + return useQuery({ + queryKey: locationKeys.geofenceRatesCurrent, + queryFn: ({ signal }) => request('/geofences/rates/current', { signal }), + select: safeArray, + staleTime: 15_000, + refetchInterval: 30_000, + }); +} + +/** Shared invalidation for the three archive-lifecycle mutations below. */ +function invalidateGeofenceLifecycle(qc: ReturnType) { + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofences }); + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofencesNeedsReview }); + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofenceRatesCurrent }); +} + +/** + * useArchiveGeofence — POST /geofences/{geofenceID}/archive + * + * Soft-deletes a charging place: idempotent, excludes it from default + * active listings, but keeps it resolvable by id for historical charging + * activity (never a hard delete once a place has sessions/rates). + */ +export function useArchiveGeofence() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (geofenceId: number) => + request(`/geofences/${geofenceId}/archive`, { method: 'POST' }), + onSuccess: () => { + invalidateGeofenceLifecycle(qc); + success('toast.geofence.archive.success', 'Place archived'); + }, + onError: (err) => error(err, 'toast.geofence.archive.error', 'Failed to archive place'), + }); +} + +/** + * useUnarchiveGeofence — POST /geofences/{geofenceID}/unarchive + * + * Restores a previously archived place to the default active listings. + * Idempotent. + */ +export function useUnarchiveGeofence() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (geofenceId: number) => + request(`/geofences/${geofenceId}/unarchive`, { method: 'POST' }), + onSuccess: () => { + invalidateGeofenceLifecycle(qc); + success('toast.geofence.unarchive.success', 'Place restored'); + }, + onError: (err) => error(err, 'toast.geofence.unarchive.error', 'Failed to restore place'), + }); +} + +/** + * useMarkGeofenceReviewed — POST /geofences/{geofenceID}/reviewed + * + * Clears `needs_review` once a human has confirmed/edited an + * auto-discovered place's name, category, or location — removes it from + * the "Needs Setup" queue without any other change. + */ +export function useMarkGeofenceReviewed() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (geofenceId: number) => + request(`/geofences/${geofenceId}/reviewed`, { method: 'POST' }), + onSuccess: () => { + invalidateGeofenceLifecycle(qc); + success('toast.geofence.reviewed.success', 'Marked reviewed'); + }, + onError: (err) => error(err, 'toast.geofence.reviewed.error', 'Failed to mark reviewed'), + }); +} + +/** Rename a charging place without requiring the caller to resend geometry. */ +export function useRenameGeofence() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ geofenceId, name }: { geofenceId: number; name: string }) => + request(`/geofences/${geofenceId}`, { + method: 'PUT', + body: JSON.stringify({ name }), + }), + onSuccess: () => { + invalidateGeofenceLifecycle(qc); + success('toast.geofence.rename.success', 'Place renamed'); + }, + onError: (err) => error(err, 'toast.geofence.rename.error', 'Failed to rename place'), + }); +} + +/** Update a charging place category without resending its geometry. */ +export function useUpdateGeofenceCategory() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ + geofenceId, + category, + }: { + geofenceId: number; + category: GeofenceCategory; + }) => + request(`/geofences/${geofenceId}`, { + method: 'PUT', + body: JSON.stringify({ category }), + }), + onSuccess: () => { + invalidateGeofenceLifecycle(qc); + success('toast.geofence.category.success', 'Category updated'); + }, + onError: (err) => + error(err, 'toast.geofence.category.error', 'Failed to update category'), + }); +} + +/** + * useGeofenceRates — GET /geofences/{geofenceID}/rates + * + * Every time-versioned rate row for one place, newest `effective_from` + * first — the shape the rate-history panel renders directly. + */ +export function useGeofenceRates(geofenceId?: number) { + return useQuery({ + queryKey: locationKeys.geofenceRates(geofenceId ?? 0), + queryFn: ({ signal }) => request(`/geofences/${geofenceId}/rates`, { signal }), + enabled: !!geofenceId, + select: safeArray, + }); +} + +/** + * useCreateGeofenceRate — POST /geofences/{geofenceID}/rates + * + * Adds a new time-versioned rate for a place — first-time setup, a future + * scheduled change, or a correction. There is no separate "replace" + * endpoint: a correction is just another call with an `effective_from` at + * or after the point the correction should take hold; an open-ended new + * version closes the existing unbounded interval. Callers pass `rate_per_wh` + * (never a per-kWh value) — convert user-entered currency/kWh at the call + * site (see charging-places/helpers.ts). + */ +export function useCreateGeofenceRate() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ geofenceId, ...body }: { geofenceId: number } & GeofenceRateCreateRequest) => + request(`/geofences/${geofenceId}/rates`, { + method: 'POST', + body: JSON.stringify(body), + }), + onSuccess: (_data, vars) => { + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofenceRates(vars.geofenceId) }); + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofenceRatesCurrent }); + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofences }); + success('toast.geofenceRate.create.success', 'Rate saved'); + }, + onError: (err) => error(err, 'toast.geofenceRate.create.error', 'Failed to save rate'), + }); +} + +/** + * useDeleteGeofenceRate — DELETE /geofences/{geofenceID}/rates/{rateID} + * + * Cancels an unused future schedule. Effective or referenced rates are + * immutable; the backend restores the prior adjacent interval so no future + * pricing gap is introduced. + */ +export function useDeleteGeofenceRate() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ geofenceId, rateId }: { geofenceId: number; rateId: number }) => + request(`/geofences/${geofenceId}/rates/${rateId}`, { method: 'DELETE' }), + onSuccess: (_data, vars) => { + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofenceRates(vars.geofenceId) }); + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofenceRatesCurrent }); + success('toast.geofenceRate.delete.success', 'Scheduled rate cancelled'); + }, + onError: (err) => + error(err, 'toast.geofenceRate.delete.error', 'Failed to cancel scheduled rate'), + }); +} + +/** + * useGeofenceRatePreview — GET + * /geofences/{geofenceID}/rates/{rateID}/preview[?from&to] + * + * Read-only: reports how many charging sessions are matched (in scope by + * place + time), eligible (would actually be repriced — unpriced or + * previously geofence-derived), and protected (in scope but already carry + * a manual/Tesla-actual cost or an existing cost with unknown provenance), + * plus the estimated total cost at this rate. `from`/`to` optionally narrow + * the scope further; both default to the rate's own effective interval. + * Disabled until both ids are known (e.g. before a rate row is selected). + */ +export function useGeofenceRatePreview( + geofenceId?: number, + rateId?: number, + range: { from?: string; to?: string } = {}, +) { + return useQuery({ + queryKey: locationKeys.geofenceRatePreview(geofenceId ?? 0, rateId ?? 0, range.from, range.to), + queryFn: ({ signal }) => { + const params = new URLSearchParams(); + if (range.from) params.set('from', range.from); + if (range.to) params.set('to', range.to); + const qs = params.toString(); + return request( + `/geofences/${geofenceId}/rates/${rateId}/preview${qs ? `?${qs}` : ''}`, + { signal }, + ); + }, + enabled: !!geofenceId && !!rateId, + }); +} + +/** + * useApplyGeofenceRate — POST + * /geofences/{geofenceID}/rates/{rateID}/apply[?from&to] + * + * The write-performing, explicit backfill/reprice action — the only way + * historical sessions are ever repriced. Bounded to this geofence + rate's + * interval (optionally narrowed further by `from`/`to`), idempotent, and + * never overwrites a manual/Tesla-actual cost or an existing cost with + * unknown provenance. On success, invalidates this place's charging + * summary/activity AND the global charging-sessions cache, since an apply + * can change `cost_decimal`/`cost_source` on many rows at once. + */ +export function useApplyGeofenceRate() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ + geofenceId, + rateId, + from, + to, + }: { + geofenceId: number; + rateId: number; + from?: string; + to?: string; + }) => { + const params = new URLSearchParams(); + if (from) params.set('from', from); + if (to) params.set('to', to); + const qs = params.toString(); + return request( + `/geofences/${geofenceId}/rates/${rateId}/apply${qs ? `?${qs}` : ''}`, + { method: 'POST' }, + ); + }, + onSuccess: (_data, vars) => { + invalidateAndBroadcast(qc, { + queryKey: ['geofences', vars.geofenceId, 'rates', vars.rateId, 'preview'], + }); + invalidateAndBroadcast(qc, { queryKey: locationKeys.geofenceChargingSummary(vars.geofenceId) }); + // charging-activity keys are parameterized by limit/offset per page; + // invalidate the whole ['geofences', id, 'charging-activity'] subtree + // (TanStack matches by prefix) rather than enumerate every page. + invalidateAndBroadcast(qc, { + queryKey: ['geofences', vars.geofenceId, 'charging-activity'], + }); + invalidateAndBroadcast(qc, { queryKey: chargingKeys.all }); + success('toast.geofenceRate.apply.success', 'Rate applied to matching sessions'); + }, + onError: (err) => error(err, 'toast.geofenceRate.apply.error', 'Failed to apply rate'), + }); +} + +/** + * useGeofenceChargingSummary — GET /geofences/{geofenceID}/charging-summary + * + * Priced charging-activity totals for one place, ALWAYS grouped by + * currency (never summed across currencies) — one array entry per + * currency this place has ever billed in. + */ +export function useGeofenceChargingSummary(geofenceId?: number) { + return useQuery({ + queryKey: locationKeys.geofenceChargingSummary(geofenceId ?? 0), + queryFn: ({ signal }) => + request(`/geofences/${geofenceId}/charging-summary`, { signal }), + enabled: !!geofenceId, + select: safeArray, + }); +} + +/** + * useGeofenceChargingActivity — GET + * /geofences/{geofenceID}/charging-activity[?limit&offset] + * + * Paginated session-level activity feed for one place (any pricing + * state — not just priced rows) backing the rate-history / + * affected-sessions UI panel. Mirrors the backend's `apiparams.Pagination` + * defaults (limit 50, offset 0) when not given explicitly. + */ +export function useGeofenceChargingActivity( + geofenceId?: number, + limit = 50, + offset = 0, +) { + return useQuery({ + queryKey: locationKeys.geofenceChargingActivity(geofenceId ?? 0, limit, offset), + queryFn: ({ signal }) => + request( + `/geofences/${geofenceId}/charging-activity?limit=${limit}&offset=${offset}`, + { signal }, + ), + enabled: !!geofenceId, + select: safeArray, + }); +} diff --git a/web/src/api/hooks/useNotifications.ts b/web/src/api/hooks/useNotifications.ts index 7438f4edb1..15fb21fe61 100644 --- a/web/src/api/hooks/useNotifications.ts +++ b/web/src/api/hooks/useNotifications.ts @@ -68,6 +68,7 @@ export type NotificationChannelInput = export const notificationKeys = { alerts: ['alerts'] as const, + alertHistory: (limit: number) => ['alerts', 'history', limit] as const, alertDetail: (id: number) => ['alerts', 'detail', id] as const, alertRules: ['alert-rules'] as const, alertMetrics: ['alert-metrics'] as const, @@ -138,6 +139,21 @@ export function useAlerts() { }); } +/** + * Fetches the largest backend-supported alert page for analytical views. + * Ordinary inbox consumers keep the lightweight polling query above. + */ +export function useAlertHistory(limit = 1000) { + const boundedLimit = Math.max(1, Math.min(1000, Math.floor(limit))); + return useQuery({ + queryKey: notificationKeys.alertHistory(boundedLimit), + queryFn: ({ signal }) => + request(`/alerts?limit=${boundedLimit}`, { signal }), + staleTime: STALE_TIMES.MODERATE, + select: safeArray, + }); +} + export function useMarkAlertRead() { const { success, error } = useMutationToast(); return useOptimisticMutation({ diff --git a/web/src/api/hooks/useVehicleCommand.test.tsx b/web/src/api/hooks/useVehicleCommand.test.tsx index 13107e3ec1..1b8e91a27a 100644 --- a/web/src/api/hooks/useVehicleCommand.test.tsx +++ b/web/src/api/hooks/useVehicleCommand.test.tsx @@ -47,9 +47,9 @@ vi.mock('../client', async () => { return { ...actual, request: vi.fn() }; }); -vi.mock('@/components/feedback/Toast', async () => { - const actual = await vi.importActual( - '@/components/feedback/Toast', +vi.mock('@/components/feedback', async () => { + const actual = await vi.importActual( + '@/components/feedback', ); return { ...actual, @@ -178,9 +178,9 @@ describe('useVehicleCommand — success', () => { const keys = invalidatedKeys(invalidateSpy); expect(invalidateSpy).toHaveBeenCalledTimes(4); expect(keys).toContainEqual(['vehicle-state', 7]); - expect(keys).toContainEqual(['command-latest', 7]); - // The command-history key is stringified so a numeric id invalidation still - // hits a query primed with the string route param. + // Both command query keys are normalized so numeric ids invalidate + // consumers initialized from string route params (and vice versa). + expect(keys).toContainEqual(['command-latest', '7']); expect(keys).toContainEqual(['command-history', '7']); expect(keys).toContainEqual(['vehicles']); }); diff --git a/web/src/api/hooks/useVehicleCommand.ts b/web/src/api/hooks/useVehicleCommand.ts index 96089bf9c8..55d80d1827 100644 --- a/web/src/api/hooks/useVehicleCommand.ts +++ b/web/src/api/hooks/useVehicleCommand.ts @@ -1,8 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { request } from '../client'; -import { useToast } from '@/components/feedback/Toast'; +import { useToast } from '@/components/feedback'; import { vehicleKeys } from './useVehicles'; +import { commandKeys } from './useCommands'; import { isTeslaAuthExpiredError } from '@/lib/resilience'; import { queueTeslaMutation } from '@/lib/teslaAuthRecovery'; @@ -19,7 +20,7 @@ import { queueTeslaMutation } from '@/lib/teslaAuthRecovery'; * forward-compatibility with a future handler revision that returns a * friendlier human string; today the failure reason lives in `error`. */ -interface CommandResult { +export interface CommandResult { success?: boolean; /** Present on the success path (currently the literal `"success"`). */ result?: string; @@ -29,7 +30,7 @@ interface CommandResult { message?: string; } -interface SendCommandParams { +export interface SendCommandParams { vehicleId: number; command: string; params?: Record; @@ -60,8 +61,8 @@ export function useVehicleCommand() { }), onSuccess: (data, { vehicleId }) => { queryClient.invalidateQueries({ queryKey: vehicleKeys.state(vehicleId) }); - queryClient.invalidateQueries({ queryKey: ['command-latest', vehicleId] }); - queryClient.invalidateQueries({ queryKey: ['command-history', String(vehicleId)] }); + queryClient.invalidateQueries({ queryKey: commandKeys.latest(vehicleId) }); + queryClient.invalidateQueries({ queryKey: commandKeys.history(vehicleId) }); queryClient.invalidateQueries({ queryKey: vehicleKeys.all }); if (data?.success) { toast.success(data.message || t('commands.toast.success', 'Command sent successfully')); diff --git a/web/src/api/hooks/useVehicles.test.tsx b/web/src/api/hooks/useVehicles.test.tsx index c2d822371c..01def2198d 100644 --- a/web/src/api/hooks/useVehicles.test.tsx +++ b/web/src/api/hooks/useVehicles.test.tsx @@ -94,6 +94,10 @@ import { useRefreshVehicleUpgrades, useWarrantyDetails, useRefreshWarrantyDetails, + useVehiclePricing, + useEnterpriseRoles, + useRefreshEnterpriseRoles, + useSetEnterprisePayer, } from './useVehicles'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -602,6 +606,9 @@ describe.each(refreshFamily)('$label', ({ hook, segment, invalidateKey, toastKey expect(lastUrl()).toBe(`/vehicles/5/${segment}/refresh`); expect(lastOpts().method).toBe('POST'); + if (segment === 'specs') { + expect(lastOpts().body).toBe(JSON.stringify({ confirmed: true })); + } expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: invalidateKey }); await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith(toastKey, expect.any(String))); }); @@ -742,27 +749,34 @@ describe('useWakeVehicle', () => { // ───────────────────────────────────────────────────────────────────────────── describe('warranty details', () => { - it('useWarrantyDetails GETs /tesla/warranty', async () => { + it('useWarrantyDetails GETs the selected vehicle warranty route', async () => { requestMock.mockResolvedValueOnce({ data: { in_warranty: true }, fetched_at: null }); - const { result } = renderH(() => useWarrantyDetails()); + const { result } = renderH(() => useWarrantyDetails('5')); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(lastUrl()).toBe('/tesla/warranty'); + expect(lastUrl()).toBe('/vehicles/5/warranty'); expect(lastOpts()).toHaveProperty('signal'); }); + it('useWarrantyDetails stays idle without a selected vehicle', async () => { + const { result } = renderH(() => useWarrantyDetails()); + await tick(); + expect(requestMock).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe('idle'); + }); + it('useRefreshWarrantyDetails POSTs the refresh route, invalidates, and toasts', async () => { requestMock.mockResolvedValueOnce({ data: {}, fetched_at: '2025-01-01T00:00:00Z' }); const client = makeClient(); const invalidateQueries = vi.spyOn(client, 'invalidateQueries'); - const { result } = renderHook(() => useRefreshWarrantyDetails(), { + const { result } = renderHook(() => useRefreshWarrantyDetails('5'), { wrapper: makeWrapper(client), }); await result.current.mutateAsync(); - expect(lastUrl()).toBe('/tesla/warranty/refresh'); + expect(lastUrl()).toBe('/vehicles/5/warranty/refresh'); expect(lastOpts().method).toBe('POST'); - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['warranty-details'] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['warranty-details', '5'] }); await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith( 'toast.vehicles.warranty.refresh.success', @@ -771,3 +785,82 @@ describe('warranty details', () => { ); }); }); + +describe('official vehicle-management partner hooks', () => { + it('submits pricing to the fixed route with only the opaque payload wrapper', async () => { + requestMock.mockResolvedValueOnce({ data: { quote: 17 } }); + const { result } = renderH(() => useVehiclePricing()); + const payload = { opaque: { nested: [1, true] } }; + + await result.current.mutateAsync({ payload }); + + expect(lastUrl()).toBe('/tesla/vehicle-pricing'); + expect(lastOpts().method).toBe('POST'); + expect(JSON.parse(String(lastOpts().body))).toEqual({ payload }); + await waitFor(() => + expect(toastSuccess).toHaveBeenCalledWith( + 'toast.vehicles.pricing.success', + expect.any(String), + ), + ); + }); + + it('reads cached enterprise roles only when a vehicle is selected', async () => { + requestMock.mockResolvedValueOnce({ + data: { roles: ['fleet_manager'] }, + fetched_at: '2026-08-08T12:00:00Z', + }); + const { result } = renderH(() => useEnterpriseRoles('5')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lastUrl()).toBe('/vehicles/5/enterprise-roles'); + expect(lastOpts()).toHaveProperty('signal'); + + requestMock.mockClear(); + renderH(() => useEnterpriseRoles(undefined)); + await tick(); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('refreshes enterprise roles explicitly and invalidates the cached GET', async () => { + requestMock.mockResolvedValueOnce({ + data: { roles: [] }, + fetched_at: '2026-08-08T12:00:00Z', + }); + const client = makeClient(); + const invalidateQueries = vi.spyOn(client, 'invalidateQueries'); + const { result } = renderHook(() => useRefreshEnterpriseRoles('5'), { + wrapper: makeWrapper(client), + }); + + await result.current.mutateAsync(); + + expect(lastUrl()).toBe('/vehicles/5/enterprise-roles/refresh'); + expect(lastOpts().method).toBe('POST'); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['vehicle-enterprise-roles', '5'], + }); + }); + + it('sends the exact payer object with explicit confirmation and invalidates roles', async () => { + requestMock.mockResolvedValueOnce({ data: { updated: true } }); + const client = makeClient(); + const invalidateQueries = vi.spyOn(client, 'invalidateQueries'); + const { result } = renderHook(() => useSetEnterprisePayer('5'), { + wrapper: makeWrapper(client), + }); + const payload = { opaque: { nested: [1, true] } }; + + await result.current.mutateAsync({ payload, confirmed: true }); + + expect(lastUrl()).toBe('/vehicles/5/enterprise-payer'); + expect(lastOpts().method).toBe('POST'); + expect(JSON.parse(String(lastOpts().body))).toEqual({ + payload, + confirmed: true, + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['vehicle-enterprise-roles', '5'], + }); + }); +}); diff --git a/web/src/api/hooks/useVehicles.ts b/web/src/api/hooks/useVehicles.ts index 4ad05743dc..8e5961ed9e 100644 --- a/web/src/api/hooks/useVehicles.ts +++ b/web/src/api/hooks/useVehicles.ts @@ -6,7 +6,14 @@ import { useMutationToast } from './_toastHelpers'; import { invalidateAndBroadcast } from '@/lib/queryBroadcast'; import { useAsOfDate, AS_OF_QUERY_PARAM } from '@/hooks/useAsOfDate'; import type { Vehicle } from '@/types/vehicle'; -import type { VehicleState } from '../types'; +import type { + EnterprisePayerVariables, + TeslaJSONValue, + VehicleInfoEnvelope, + VehicleManagementResult, + VehiclePricingVariables, + VehicleState, +} from '../types'; export { deriveVehicleStatus as getVehicleStatus } from '../types'; export const vehicleKeys = { @@ -384,11 +391,6 @@ export function useFleetStates(vehicles: Vehicle[]) { // ---------- Vehicle Info (mobile enabled, options, specs) ---------- -interface VehicleInfoEnvelope { - data: T | null; - fetched_at: string | null; -} - interface MobileEnabledData { enabled: boolean; } @@ -450,7 +452,10 @@ export function useRefreshVehicleSpecs(vehicleId?: string) { const queryClient = useQueryClient(); const { success, error } = useMutationToast(); return useMutation({ - mutationFn: () => request>>(`/vehicles/${vehicleId}/specs/refresh`, { method: 'POST' }), + mutationFn: () => request>>(`/vehicles/${vehicleId}/specs/refresh`, { + method: 'POST', + body: JSON.stringify({ confirmed: true }), + }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vehicle-specs', vehicleId] }); success('toast.vehicles.specs.refresh.success', 'Vehicle specs refreshed'); @@ -509,23 +514,119 @@ export function useRefreshVehicleUpgrades(vehicleId?: string) { // ---------- Warranty Details ---------- -export function useWarrantyDetails() { +export function useWarrantyDetails(vehicleId?: string) { return useQuery({ - queryKey: ['warranty-details'], - queryFn: ({ signal }) => request>>('/tesla/warranty', { signal }), + queryKey: ['warranty-details', vehicleId], + queryFn: ({ signal }) => request>>(`/vehicles/${vehicleId}/warranty`, { signal }), + enabled: !!vehicleId, staleTime: STALE_TIMES.DAILY, }); } -export function useRefreshWarrantyDetails() { +export function useRefreshWarrantyDetails(vehicleId?: string) { const queryClient = useQueryClient(); const { success, error } = useMutationToast(); return useMutation({ - mutationFn: () => request>>('/tesla/warranty/refresh', { method: 'POST' }), + mutationFn: () => request>>(`/vehicles/${vehicleId}/warranty/refresh`, { method: 'POST' }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['warranty-details'] }); + queryClient.invalidateQueries({ queryKey: ['warranty-details', vehicleId] }); success('toast.vehicles.warranty.refresh.success', 'Warranty details refreshed'); }, onError: (e) => error(e, 'toast.vehicles.warranty.refresh.error', 'Failed to refresh warranty details'), }); } + +// ---------- Official Vehicle Management: pricing + enterprise ---------- + +export function useVehiclePricing() { + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ payload }: VehiclePricingVariables) => + request('/tesla/vehicle-pricing', { + method: 'POST', + body: JSON.stringify({ payload }), + }), + onSuccess: () => { + success( + 'toast.vehicles.pricing.success', + 'Tesla vehicle pricing query completed', + ); + }, + onError: (e) => + error( + e, + 'toast.vehicles.pricing.error', + 'Failed to query Tesla vehicle pricing', + ), + }); +} + +export function useEnterpriseRoles(vehicleId?: string) { + return useQuery({ + queryKey: ['vehicle-enterprise-roles', vehicleId], + queryFn: ({ signal }) => + request>( + `/vehicles/${vehicleId}/enterprise-roles`, + { signal }, + ), + enabled: !!vehicleId, + staleTime: STALE_TIMES.RARE, + }); +} + +export function useRefreshEnterpriseRoles(vehicleId?: string) { + const queryClient = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: () => + request>( + `/vehicles/${vehicleId}/enterprise-roles/refresh`, + { method: 'POST' }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['vehicle-enterprise-roles', vehicleId], + }); + success( + 'toast.vehicles.enterpriseRoles.refresh.success', + 'Enterprise roles refreshed', + ); + }, + onError: (e) => + error( + e, + 'toast.vehicles.enterpriseRoles.refresh.error', + 'Failed to refresh enterprise roles', + ), + }); +} + +export function useSetEnterprisePayer(vehicleId?: string) { + const queryClient = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ payload, confirmed }: EnterprisePayerVariables) => + request( + `/vehicles/${vehicleId}/enterprise-payer`, + { + method: 'POST', + body: JSON.stringify({ payload, confirmed }), + }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['vehicle-enterprise-roles', vehicleId], + }); + success( + 'toast.vehicles.enterprisePayer.success', + 'Enterprise payer updated', + ); + }, + onError: (e) => + error( + e, + 'toast.vehicles.enterprisePayer.error', + 'Failed to update enterprise payer', + ), + }); +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 4f0f47a3f9..bae1917811 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -63,6 +63,38 @@ export interface Vehicle { updated_at: string } +/** JSON values returned by Tesla-controlled, undocumented Fleet API schemas. */ +export type TeslaJSONValue = + | string + | number + | boolean + | null + | TeslaJSONValue[] + | TeslaOpaqueObject + +/** Opaque Tesla-controlled JSON object for undocumented request schemas. */ +export type TeslaOpaqueObject = { [key: string]: TeslaJSONValue } + +/** Cached vehicle-management response stored in tesla_user_config. */ +export interface VehicleInfoEnvelope { + data: T | null + fetched_at: string | null +} + +/** Non-persisted response from an opaque Vehicle Management mutation. */ +export interface VehicleManagementResult { + data: TeslaJSONValue +} + +export interface VehiclePricingVariables { + payload: TeslaOpaqueObject +} + +export interface EnterprisePayerVariables { + payload: TeslaOpaqueObject + confirmed: boolean +} + // VehicleLiveState removed — vehicle_live_state table dropped. // Use VehicleState (from /vehicles/{id}/state via SignalStore) or // VehicleLiveState from hooks/useVehicleLive (SSE) instead. @@ -217,15 +249,158 @@ export interface ChargeTelemetryReading { current_amps?: number | null } +// === Geofences / Charging Places === +// +// Canonical snake_case wire shape for GET/POST/PUT /geofences and the +// charging-place pricing feature's endpoints beneath +// /geofences/{geofenceID}/... (see internal/api/geofence/rate_handler.go +// and internal/models/system/{system,geofence_rate}.go — the source of +// truth for every field below). +// +// `rate_per_wh` is the ONLY canonical electricity-rate unit on the wire — +// never `_kwh`. Convert to currency/kWh strictly at the render/request +// boundary (see features/maps/components/charging-places/helpers.ts). + +/** How a geofence came to exist. */ +export type GeofenceOrigin = 'manual' | 'charging_discovery' + +/** Optional category tag a geofence may carry. */ +export type GeofenceCategory = 'home' | 'work' | 'restricted' | 'custom' + +/** + * A geofence ("charging place" once it has rates/sessions attached). + * + * `latitude` / `longitude` / `radius` are NOT stored columns — the backend's + * `Geofence.MarshalJSON` derives them on every read from `polygon_wkt` + * (centroid + max-vertex-distance in meters) so the web client never parses + * WKT itself. `category` / `archived_at` use `omitempty` on the Go side: + * they are ABSENT from the payload (not `null`) when unset. + */ export interface Geofence { id: number name: string + polygon_wkt: string + category?: GeofenceCategory | null + enabled: boolean + alert_on_entry: boolean + alert_on_exit: boolean + origin: GeofenceOrigin + needs_review: boolean + archived_at?: string | null + created_at: string + updated_at: string + /** Computed centroid latitude, degrees — see MarshalJSON note above. */ latitude: number + /** Computed centroid longitude, degrees — see MarshalJSON note above. */ longitude: number + /** Computed bounding radius, meters — see MarshalJSON note above. */ radius: number - cost_per_kwh: number | null +} + +/** + * One time-versioned electricity-rate row for a geofence. The canonical, + * append-only source of truth — there is no separate mutable "current + * rate" column anywhere. The active rate for any instant `t` is whichever + * row's half-open `[effective_from, effective_to)` interval contains `t`; + * `effective_to: null` means "still open" (the current version). + */ +export interface GeofenceRate { + id: number + geofence_id: number + /** Currency units per **watt-hour** — SI-canonical, never per-kWh. */ + rate_per_wh: number + /** ISO-4217 currency code, e.g. "USD". */ + currency: string + effective_from: string + effective_to?: string | null created_at: string - updated_at?: string +} + +/** Request body for `POST /geofences/{geofenceID}/rates`. */ +export interface GeofenceRateCreateRequest { + rate_per_wh: number + currency: string + effective_from: string + effective_to?: string | null +} + +/** + * Charging-session cost provenance values, mirroring the + * `charging_sessions.cost_source` CHECK constraint. Precedence (highest to + * lowest confidence): manual actual > tesla_actual > geofence_tariff > + * default_estimate > unknown. + */ +export type CostSource = + | 'manual' + | 'tesla_actual' + | 'geofence_tariff' + | 'default_estimate' + | 'unknown' + +/** + * Read-only "what would applying this rate do" response for + * `GET /geofences/{geofenceID}/rates/{rateID}/preview` — no rows written. + * `eligible_sessions` is the subset of `matched_sessions` an apply call is + * actually allowed to touch (unpriced or previously geofence-derived); + * `protected_sessions` already carry a manual/Tesla-actual cost and are + * matched (in scope by place + time) but will never be overwritten. + */ +export interface GeofenceRateImpactPreview { + geofence_id: number + rate_id: number + currency: string + matched_sessions: number + eligible_sessions: number + protected_sessions: number + total_energy_wh: number + estimated_cost_decimal: number +} + +/** + * Outcome of an explicit apply/backfill action — + * `POST /geofences/{geofenceID}/rates/{rateID}/apply`. The + * write-performing counterpart of {@link GeofenceRateImpactPreview}. + */ +export interface GeofenceRateApplyResult { + geofence_id: number + rate_id: number + currency: string + matched_sessions: number + priced_sessions: number + skipped_sessions: number + total_energy_wh: number + total_cost_decimal: number +} + +/** + * A geofence's priced charging activity totals for ONE currency — + * `GET /geofences/{geofenceID}/charging-summary` always returns an array, + * one entry per currency ever seen at this place. Different currencies are + * NEVER summed into a single total; callers must group/scope by currency. + */ +export interface GeofenceChargingSummary { + geofence_id: number + currency: string + session_count: number + total_energy_wh: number + total_cost_decimal: number +} + +/** + * One line item in a geofence's charging-session activity feed — + * `GET /geofences/{geofenceID}/charging-activity` (paginated via + * `limit`/`offset` query params; any pricing state, not just priced rows). + */ +export interface GeofenceChargingActivity { + session_id: number + vehicle_id: number + started_at: string + ended_at?: string | null + energy_wh?: number | null + cost_decimal?: number | null + cost_currency?: string | null + cost_source?: CostSource | null + rate_id?: number | null } export interface AppSettings { diff --git a/web/src/components/forms/__tests__/RangePicker.test.tsx b/web/src/components/forms/__tests__/RangePicker.test.tsx index 5c9e712907..acad7bb2c0 100644 --- a/web/src/components/forms/__tests__/RangePicker.test.tsx +++ b/web/src/components/forms/__tests__/RangePicker.test.tsx @@ -12,6 +12,7 @@ describe(' trigger', () => { render(); const trigger = screen.getByRole('button', { name: /date range/i }); expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent('Custom range'); // The readout always renders the resolved dates. expect(trigger).toHaveTextContent(/jan/i); }); diff --git a/web/src/components/layout/Breadcrumbs.tsx b/web/src/components/layout/Breadcrumbs.tsx index f10009005e..c1d1a992bd 100644 --- a/web/src/components/layout/Breadcrumbs.tsx +++ b/web/src/components/layout/Breadcrumbs.tsx @@ -32,7 +32,7 @@ export function Breadcrumbs({ homeAriaLabel, }: BreadcrumbsProps) { const { t } = useTranslation(); - if (items.length <= 1) return null; + if (items.length === 0) return null; return (