Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6316783c3a | |||
| cdfe8881d9 | |||
| 8d0bc90f9d | |||
| 12a7447494 | |||
| dd2f24316c | |||
| 90abc536dd |
10
docs/api.md
10
docs/api.md
@@ -194,10 +194,14 @@ GET /conditions/current?format=json&precision=0
|
|||||||
GET /alerts/active
|
GET /alerts/active
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest active-alert snapshot.
|
Returns the latest stored alert run filtered to alerts active at request time.
|
||||||
|
|
||||||
Query parameters: `format`, `units`.
|
Query parameters: `format`, `units`.
|
||||||
|
|
||||||
|
When no latest alert run exists, `data` is null. When a latest run exists but
|
||||||
|
no alerts are currently active, `data` remains an object and `alerts` is an
|
||||||
|
empty array.
|
||||||
|
|
||||||
Run `data` fields:
|
Run `data` fields:
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
@@ -207,6 +211,10 @@ Run `data` fields:
|
|||||||
| `latitude`, `longitude` | number | optional |
|
| `latitude`, `longitude` | number | optional |
|
||||||
| `alerts` | array | active alerts, possibly empty |
|
| `alerts` | array | active alerts, possibly empty |
|
||||||
|
|
||||||
|
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
|
||||||
|
at or before request time, and `expires` is absent or after request time.
|
||||||
|
`onset` is presented when available but is not used as the active boundary.
|
||||||
|
|
||||||
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ This document describes the internal HTTP adapter under
|
|||||||
The HTTP adapter turns feedapi route definitions into calls on the application
|
The HTTP adapter turns feedapi route definitions into calls on the application
|
||||||
service boundary. It owns route registration, query binding, request validation,
|
service boundary. It owns route registration, query binding, request validation,
|
||||||
forecast day-slice filtering, outlook active/location filter construction,
|
forecast day-slice filtering, outlook active/location filter construction,
|
||||||
response envelopes, and template names.
|
alert active-time selection, response envelopes, and template names.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ The adapter may:
|
|||||||
- call the `Service` interface;
|
- call the `Service` interface;
|
||||||
- choose the presenter function for an endpoint;
|
- choose the presenter function for an endpoint;
|
||||||
- filter forecast copies for `/today` and `/tomorrow`;
|
- filter forecast copies for `/today` and `/tomorrow`;
|
||||||
|
- pass the current UTC instant to active-alert application filtering;
|
||||||
- construct outlook active/location filters.
|
- construct outlook active/location filters.
|
||||||
|
|
||||||
The adapter must not:
|
The adapter must not:
|
||||||
@@ -61,9 +62,9 @@ declared in endpoint definitions, but `templates.base_dir` is loaded by feedapi.
|
|||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
The adapter has no durable state. `forecastNow` and `outlookNow` are
|
The adapter has no durable state. `forecastNow`, `alertNow`, and `outlookNow`
|
||||||
package-level state only to make time-dependent endpoint tests deterministic.
|
are package-level state only to make time-dependent endpoint tests
|
||||||
Do not add request caches or cross-request mutable state here.
|
deterministic. Do not add request caches or cross-request mutable state here.
|
||||||
|
|
||||||
## Route Registry
|
## Route Registry
|
||||||
|
|
||||||
@@ -131,6 +132,12 @@ Filtering behavior:
|
|||||||
The package variable `forecastNow` exists so endpoint tests can make day-slice
|
The package variable `forecastNow` exists so endpoint tests can make day-slice
|
||||||
behavior deterministic.
|
behavior deterministic.
|
||||||
|
|
||||||
|
## Alert Active Time
|
||||||
|
|
||||||
|
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
|
||||||
|
the application service with `alertNow().UTC()` so active alert filtering uses
|
||||||
|
the request-time instant while remaining deterministic in endpoint tests.
|
||||||
|
|
||||||
## Outlook Filters
|
## Outlook Filters
|
||||||
|
|
||||||
Outlook route filters are built at the HTTP boundary and passed to the
|
Outlook route filters are built at the HTTP boundary and passed to the
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ successful responses with `data: null`.
|
|||||||
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
||||||
application-provided observation window.
|
application-provided observation window.
|
||||||
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
||||||
`alert_references`.
|
`alert_references`. This is the latest stored alert snapshot; active-time
|
||||||
|
filtering is performed by the application service.
|
||||||
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
||||||
then child `forecast_periods`.
|
then child `forecast_periods`.
|
||||||
- `LatestNarrativeForecast`: latest `forecasts` row where
|
- `LatestNarrativeForecast`: latest `forecasts` row where
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ Outputs are HTTP responses in JSON, XML, or text format. All public endpoint han
|
|||||||
|
|
||||||
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
|
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
|
||||||
|
|
||||||
The API currently serves latest-resource views: latest observation, current conditions, latest active alerts run, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Forecast `today` and `tomorrow` routes derive filtered copies from the latest run. Convective outlook active and location routes derive filtered copies from the latest run.
|
The API currently serves latest-resource views: latest observation, current conditions, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Active alert, forecast `today` and `tomorrow`, and convective outlook active/location routes derive filtered copies from the latest stored run.
|
||||||
|
|
||||||
## Configuration and CLI Boundaries
|
## Configuration and CLI Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -1,287 +1,121 @@
|
|||||||
# Implement Weatherfeeder Outlook V2 Support
|
# Active Alert Filtering Cleanup
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Implementation, current-behavior documentation, and automated verification are
|
Implementation, current-behavior documentation, and automated verification are
|
||||||
complete. This file now serves as the checklist and verification record for the
|
complete.
|
||||||
outlook v2 compatibility update. Authoritative implemented behavior is
|
|
||||||
documented in `docs/api.md`, `docs/integrations/weatherfeeder-postgres.md`, and
|
|
||||||
`docs/internal/`.
|
|
||||||
|
|
||||||
## Verification Record
|
## Verification Record
|
||||||
|
|
||||||
Completed on 2026-06-12:
|
Completed on 2026-06-12:
|
||||||
|
|
||||||
- `go test ./internal/app`
|
- `go test ./internal/app`
|
||||||
- `go test ./internal/adapters/outbound/postgres`
|
|
||||||
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
|
||||||
- `go test ./internal/adapters/inbound/httpapi`
|
- `go test ./internal/adapters/inbound/httpapi`
|
||||||
|
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
||||||
- `go test ./...`
|
- `go test ./...`
|
||||||
- `go build ./cmd/weatherapi`
|
|
||||||
|
|
||||||
The database-backed endpoint checklist requires a reachable PostgreSQL database
|
Deployment smoke checks were not run from this workspace because the local
|
||||||
populated by weatherfeeder outlook v2 tables. It was not run in this workspace
|
repository state is not a deployed `weatherapi` instance.
|
||||||
because no such populated database was available.
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Implement `weatherapi` support for the `weatherfeeder` SPC outlook v2 contract described in `docs/roadmap/outlook.md`.
|
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
||||||
|
|
||||||
This is a compatibility update for the existing convective outlook route family. Do not add or remove public routes. Update dependency, repository reads, app filtering, presenters, templates, tests, and current-behavior docs so `weatherapi` reads `weather.outlook.v2` data from the new weatherfeeder Postgres table shape.
|
The current implementation reads the latest `alert_runs` parent row, loads all child `alerts`, and presents the run unchanged. This can expose expired alerts when the latest persisted weatherfeeder snapshot is stale or when a snapshot contains alerts that later expire before the next successful ingestion cycle.
|
||||||
|
|
||||||
## Required End State
|
This cleanup preserves the existing route, response envelope, repository contract, and weatherfeeder table ownership. The change should be implemented as app-layer filtering over the latest stored snapshot, following the same architectural pattern already used by convective outlook active filtering.
|
||||||
|
|
||||||
- `go.mod` depends on the released `weatherfeeder` version containing `weather.outlook.v2` support.
|
## Current Behavior To Correct
|
||||||
- Existing public routes remain available:
|
|
||||||
- `GET /outlooks/convective`
|
|
||||||
- `GET /outlooks/convective/active`
|
|
||||||
- `GET /outlooks/convective/location`
|
|
||||||
- `weatherapi` remains read-only and does not create, migrate, or repair weatherfeeder tables.
|
|
||||||
- The Postgres repository reads `outlook_runs`, `outlooks`, and `outlook_discussions`.
|
|
||||||
- The repository no longer expects polygon-level `headline`, `summary`, or `discussion` columns on `outlooks`.
|
|
||||||
- Returned `WeatherOutlookRun` values include run-level `Discussions` loaded from `outlook_discussions`.
|
|
||||||
- App-level filtering trims both `Outlooks` and `Discussions` so discussions are present only for days with retained outlooks.
|
|
||||||
- Presenters copy and timezone-convert run-level discussion `updatedAt` values.
|
|
||||||
- JSON/XML/text responses expose run-level `discussions` and do not expose polygon-level outlook prose fields.
|
|
||||||
- `data: null` semantics for no latest run are preserved.
|
|
||||||
- Filtered no-match responses return a non-null run with `outlooks: []` and `discussions: []`.
|
|
||||||
- Public query behavior remains compatible: `format`, `units`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation` behavior are preserved; `precision` and unknown params remain rejected.
|
|
||||||
|
|
||||||
## Guardrails
|
- `GET /alerts/active` calls `LatestAlertRun(ctx)`.
|
||||||
|
- `LatestAlertRun(ctx)` returns the latest stored alert snapshot from Postgres.
|
||||||
|
- All child alerts for that run are returned unchanged.
|
||||||
|
- The endpoint does not compare `effective`, `onset`, `expires`, `status`, or `messageType` to the request time.
|
||||||
|
- Expired alerts can therefore appear under an endpoint named `/alerts/active`.
|
||||||
|
|
||||||
- Do not implement weatherfeeder table creation or migration in weatherapi.
|
## Target Behavior
|
||||||
- Do not call SPC or any upstream weather provider from weatherapi.
|
|
||||||
- Do not introduce historical browsing or cross-run accumulation in this change.
|
|
||||||
- Do not remove `/outlooks/convective/location`; keep it as an API-compatible active local-outlook route.
|
|
||||||
- Do not keep stale v1 SQL reads for `outlooks.headline`, `outlooks.summary`, or `outlooks.discussion`.
|
|
||||||
- Do not commit a `replace` directive for `weatherfeeder`.
|
|
||||||
- Keep SQL in `*_queries.go`, row DTOs in `*_rows.go`, row mapping in `*_mapper.go`, and request/presentation policy in HTTP/presenter packages.
|
|
||||||
|
|
||||||
## Stage 1: Dependency And Compile Boundary
|
- `GET /alerts/active` returns the latest stored alert run with `alerts` filtered to items active at request time.
|
||||||
|
- Missing latest alert run still returns `{ "data": null }`.
|
||||||
|
- A latest run with no currently active alerts returns a non-null run object with `alerts: []`.
|
||||||
|
- The repository remains a latest-snapshot reader and does not own active-time policy.
|
||||||
|
- The app service owns alert active filtering policy.
|
||||||
|
- The HTTP adapter supplies the request-time instant using an injectable clock for tests.
|
||||||
|
- Presentation remains responsible only for response shape, units no-op behavior, and rendering.
|
||||||
|
|
||||||
|
## Active Alert Policy
|
||||||
|
|
||||||
|
Use a single app-layer predicate for determining whether an alert is active at an instant `activeAt`.
|
||||||
|
|
||||||
|
An alert is active when all of the following are true:
|
||||||
|
|
||||||
|
- `messageType` is not `Cancel`, case-insensitive after trimming.
|
||||||
|
- `effective` is absent or `effective <= activeAt`.
|
||||||
|
- `expires` is absent or `activeAt < expires`.
|
||||||
|
|
||||||
|
Additional policy notes:
|
||||||
|
|
||||||
|
- Do not use `onset` as a required active boundary. `onset` can describe hazard onset and may be later than alert effective time; using it as a hard lower bound could hide valid watches, warnings, or advisories that are already in effect from an alerting perspective.
|
||||||
|
- Do not filter by `status` in the first cleanup unless existing model/test data proves a specific non-active status must be excluded. NWS active feeds commonly use `Actual`; persisted historical snapshots may include other values, but time and cancellation policy are the high-confidence active criteria.
|
||||||
|
- Treat nil `expires` as active if the other criteria pass. This preserves data when an upstream alert omits an expiration, while still allowing future tightening if real data shows nil expiration should be suppressed.
|
||||||
|
- Preserve input alert order after filtering.
|
||||||
|
- Preserve run metadata such as `asOf`, location fields, latitude, and longitude even when all alerts are filtered out.
|
||||||
|
|
||||||
|
## Public API Impact
|
||||||
|
|
||||||
|
- Route remains `GET /alerts/active`.
|
||||||
|
- Supported query parameters remain `format` and `units`.
|
||||||
|
- `precision`, `tz` / `TZ`, and unknown query parameters remain rejected unless a separate roadmap explicitly changes alert query support.
|
||||||
|
- JSON/XML/text format support remains unchanged.
|
||||||
|
- Response envelope remains `{ "data": ... }`.
|
||||||
|
- No Postgres schema or weatherfeeder migration is required.
|
||||||
|
|
||||||
|
## Stage 1: App Service Filtering
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
1. Update `go.mod` to the released `gitea.maximumdirect.net/ejr/weatherfeeder` version that includes:
|
- Keep `app.Repository.LatestAlertRun(ctx)` unchanged.
|
||||||
- `standards.SchemaWeatherOutlookV2`;
|
- Add an app-level alert read use case, either:
|
||||||
- `model.WeatherOutlookRun.Discussions`;
|
- `LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)`, or
|
||||||
- `model.WeatherOutlookDiscussion`;
|
- `LatestAlerts(ctx context.Context, filter AlertFilter) (*model.WeatherAlertRun, error)` with `AlertFilter.ActiveAt`.
|
||||||
- `model.WeatherOutlook` without `Headline`, `Summary`, or `Discussion`.
|
- Prefer the first option unless another alert route is being added at the same time; it is narrower and clearer for the existing API.
|
||||||
2. Run `go mod tidy`.
|
- Implement the new service method by:
|
||||||
3. Fix compile errors from removed polygon-level outlook prose fields.
|
- reading `s.repo.LatestAlertRun(ctx)`;
|
||||||
4. Search for outlook v1 and polygon prose references:
|
- returning `nil, nil` when no run exists;
|
||||||
|
- cloning the run before mutation;
|
||||||
|
- filtering cloned `Alerts` with the active predicate;
|
||||||
|
- returning the cloned run.
|
||||||
|
- Add package-local helpers for:
|
||||||
|
- cloning `WeatherAlertRun`;
|
||||||
|
- cloning `WeatherAlert` values deeply enough to avoid mutating repository-owned slices;
|
||||||
|
- copying `References` slices;
|
||||||
|
- evaluating `isActiveAlert(alert, activeAt)`.
|
||||||
|
|
||||||
```sh
|
### Clone Requirements
|
||||||
rg "SchemaWeatherOutlookV1|weather\.outlook\.v1|\.Headline|\.Summary|\.Discussion|headline|summary|discussion" internal docs templates README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Treat matches carefully:
|
The clone must protect repository-returned data from service mutation:
|
||||||
- Keep unrelated alert and forecast-discussion uses.
|
|
||||||
- Remove or update outlook polygon-level prose references.
|
|
||||||
- Replace outlook-specific schema references with v2 where applicable.
|
|
||||||
|
|
||||||
### Expected Files
|
- allocate a new `Alerts` slice;
|
||||||
|
- allocate new `References` slices for each alert;
|
||||||
- `go.mod`
|
- copy pointer time fields only if the model uses pointer fields for alert timestamps;
|
||||||
- `go.sum`
|
- preserve string, boolean, enum, and scalar values exactly.
|
||||||
- `internal/adapters/outbound/postgres/outlooks_rows.go`
|
|
||||||
- `internal/adapters/outbound/postgres/outlooks_mapper.go`
|
|
||||||
- `internal/adapters/outbound/postgres/outlooks_queries.go`
|
|
||||||
- `internal/adapters/outbound/postgres/outlooks_read.go`
|
|
||||||
- `internal/adapters/inbound/httpapi/presenter/outlook.go`
|
|
||||||
- `internal/app/service.go`
|
|
||||||
- outlook-related tests under `internal/app`, `internal/adapters/outbound/postgres`, `internal/adapters/inbound/httpapi`, and presenter tests
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
At this stage, run a compile-oriented focused set. It may fail until later stages are complete, but failures should identify remaining v1/prose references.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/app ./internal/adapters/outbound/postgres ./internal/adapters/inbound/httpapi/presenter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 2: Postgres Query, Row, And Mapper Updates
|
|
||||||
|
|
||||||
### Query Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_queries.go`.
|
|
||||||
|
|
||||||
Parent query:
|
|
||||||
|
|
||||||
- Keep latest run ordering:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ORDER BY as_of DESC, event_emitted_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
```
|
|
||||||
|
|
||||||
- Continue selecting parent fields needed by `WeatherOutlookRun`.
|
|
||||||
- Optionally select `discussion_count` for test/sanity visibility, but do not expose it in the API response model.
|
|
||||||
|
|
||||||
Outlook child query:
|
|
||||||
|
|
||||||
- Remove these v1 columns from the `SELECT` list:
|
|
||||||
- `headline`
|
|
||||||
- `summary`
|
|
||||||
- `discussion`
|
|
||||||
- Continue selecting:
|
|
||||||
- `outlook_index`
|
|
||||||
- `outlook_id`
|
|
||||||
- `provider`
|
|
||||||
- `product`
|
|
||||||
- `day`
|
|
||||||
- `outlook_type`
|
|
||||||
- `label`
|
|
||||||
- `label_text`
|
|
||||||
- `severity_rank`
|
|
||||||
- `valid_from`
|
|
||||||
- `valid_to`
|
|
||||||
- `issued_at`
|
|
||||||
- `expires_at`
|
|
||||||
- `forecaster`
|
|
||||||
- `source_url`
|
|
||||||
- `image_url`
|
|
||||||
- `contains_location`
|
|
||||||
- `geometry_json`
|
|
||||||
- Keep `WHERE run_event_id = $1`.
|
|
||||||
- Keep `ORDER BY outlook_index ASC`.
|
|
||||||
|
|
||||||
Discussion child query:
|
|
||||||
|
|
||||||
- Add `queryOutlookDiscussionsForRun`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
discussion_index,
|
|
||||||
day,
|
|
||||||
headline,
|
|
||||||
summary,
|
|
||||||
discussion,
|
|
||||||
updated_at
|
|
||||||
FROM outlook_discussions
|
|
||||||
WHERE run_event_id = $1
|
|
||||||
ORDER BY discussion_index ASC
|
|
||||||
```
|
|
||||||
|
|
||||||
### Row DTO Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_rows.go`:
|
|
||||||
|
|
||||||
- Remove `Headline`, `Summary`, and `Discussion` from `outlookRow`.
|
|
||||||
- Add `DiscussionCount` to `outlookRunParentRow` only if selected by parent query.
|
|
||||||
- Add `outlookDiscussionRow`:
|
|
||||||
- `DiscussionIndex int`
|
|
||||||
- `Day int`
|
|
||||||
- `Headline sql.NullString`
|
|
||||||
- `Summary sql.NullString`
|
|
||||||
- `Discussion sql.NullString`
|
|
||||||
- `UpdatedAt sql.NullTime`
|
|
||||||
|
|
||||||
### Mapper Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_mapper.go`:
|
|
||||||
|
|
||||||
- `mapOutlookRow` maps only v2 polygon fields.
|
|
||||||
- Keep geometry validation with `json.Valid` and return contextual mapper errors for invalid JSON.
|
|
||||||
- Continue copying geometry bytes with `append([]byte(nil), geometry...)`.
|
|
||||||
- Continue normalizing outlook timestamps to UTC.
|
|
||||||
- Add `mapOutlookDiscussionRow` returning `model.WeatherOutlookDiscussion`:
|
|
||||||
- `Day` from row day;
|
|
||||||
- string fields via existing `stringValue` helper;
|
|
||||||
- `UpdatedAt` via existing `timePtr` helper, ensuring UTC normalization.
|
|
||||||
|
|
||||||
### Read Flow Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_read.go`:
|
|
||||||
|
|
||||||
- `LatestConvectiveOutlookRun` loads parent row as today.
|
|
||||||
- After parent row:
|
|
||||||
- call `loadOutlooks(ctx, row.EventID)`;
|
|
||||||
- call `loadOutlookDiscussions(ctx, row.EventID)`;
|
|
||||||
- attach both to the run.
|
|
||||||
- Add `loadOutlookDiscussions` mirroring `loadOutlooks` style:
|
|
||||||
- query with context;
|
|
||||||
- scan rows;
|
|
||||||
- map rows;
|
|
||||||
- return iteration errors;
|
|
||||||
- wrap query, scan, map, and iteration errors with operation context.
|
|
||||||
- Preserve `nil, nil` on missing parent row.
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
Update or add tests in `internal/adapters/outbound/postgres`:
|
Add app tests covering:
|
||||||
|
|
||||||
- `mapOutlookRow` maps v2 polygon fields and no longer expects polygon-level prose.
|
- repository delegation and error propagation;
|
||||||
- `mapOutlookRow` rejects invalid `geometry_json`.
|
- `nil, nil` when the repository has no latest alert run;
|
||||||
- nullable label/forecaster/source/image/severity fields map correctly.
|
- expired alerts are removed when `activeAt >= expires`;
|
||||||
- `mapOutlookDiscussionRow` maps day/headline/summary/discussion/updatedAt and normalizes time to UTC.
|
- alerts expiring exactly at `activeAt` are inactive;
|
||||||
- nullable discussion fields map to empty/omitted canonical values.
|
- alerts effective exactly at `activeAt` are active;
|
||||||
- latest run read loads outlooks in `outlook_index ASC` order.
|
- future-effective alerts are inactive;
|
||||||
- latest run read loads discussions in `discussion_index ASC` order.
|
- missing `effective` does not make an otherwise valid alert inactive;
|
||||||
- missing latest parent returns `nil, nil`.
|
- missing `expires` does not make an otherwise valid alert inactive;
|
||||||
- query/scan/iteration failures remain context-wrapped.
|
- `messageType=Cancel` is excluded case-insensitively;
|
||||||
|
- alert order is preserved;
|
||||||
### Verification
|
- run metadata is preserved when all alerts are filtered out;
|
||||||
|
- filtering does not mutate the repository-owned run, alerts, references, or timestamp pointers.
|
||||||
```sh
|
|
||||||
go test ./internal/adapters/outbound/postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 3: Application Filtering And Copy Semantics
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
Update `internal/app/service.go`.
|
|
||||||
|
|
||||||
Clone behavior:
|
|
||||||
|
|
||||||
- `cloneOutlookRun` must deep-copy `Discussions` in addition to `Outlooks`.
|
|
||||||
- Ensure outlook severity pointers, geometry bytes, latitude/longitude pointers, and issuedAt pointers remain copied.
|
|
||||||
- Add a helper such as `cloneOutlookDiscussion` if useful.
|
|
||||||
|
|
||||||
Filtering behavior:
|
|
||||||
|
|
||||||
- Filter `Outlooks` as today using `OutlookFilter`.
|
|
||||||
- After filtering outlooks, filter `Discussions` to only days represented by retained outlooks.
|
|
||||||
- Preserve discussion order from the repository result.
|
|
||||||
- If no outlooks remain, set discussions to an empty slice, not stale unfiltered discussions.
|
|
||||||
- Preserve nil/missing run behavior: repository `nil, nil` still returns `nil, nil`.
|
|
||||||
|
|
||||||
Recommended helper shape:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Build a set of retained outlook days.
|
|
||||||
- If the set is empty, return `[]model.WeatherOutlookDiscussion{}` when discussions was non-nil or when consistent empty-array output is desired.
|
|
||||||
- Include each discussion only if `discussion.Day` is in the set.
|
|
||||||
- Do not synthesize discussions.
|
|
||||||
|
|
||||||
### Query Compatibility
|
|
||||||
|
|
||||||
Do not change query binder behavior in this stage unless tests reveal compile fallout from the model update. Public query behavior remains:
|
|
||||||
|
|
||||||
- `day` accepted on all outlook routes.
|
|
||||||
- `outlookType` accepted on all outlook routes.
|
|
||||||
- `containsLocation` accepted on `/outlooks/convective` and `/outlooks/convective/active`.
|
|
||||||
- `containsLocation` rejected on `/outlooks/convective/location`.
|
|
||||||
- `format`, `units`, and `tz` / `TZ` accepted.
|
|
||||||
- `precision` and unknown params rejected.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Update `internal/app/service_test.go`:
|
|
||||||
|
|
||||||
- Delegation test still asserts one repository call.
|
|
||||||
- Day filter retains matching outlooks and only matching day discussions.
|
|
||||||
- Outlook type filter retains discussions only for days with retained outlooks.
|
|
||||||
- Active filter retains discussions only for days with active retained outlooks.
|
|
||||||
- `containsLocation=false` returns an empty outlook/discussion run for v2-style test data.
|
|
||||||
- Filtered no-match result has empty `Outlooks` and empty `Discussions`.
|
|
||||||
- Mutating returned outlooks/discussions does not mutate repository-owned data.
|
|
||||||
- Geometry byte copy and pointer copy behavior remains covered.
|
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
@@ -289,94 +123,33 @@ Update `internal/app/service_test.go`:
|
|||||||
go test ./internal/app
|
go test ./internal/app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stage 4: Presenter And Text Template Updates
|
## Stage 2: HTTP Adapter Wiring
|
||||||
|
|
||||||
### Presenter Changes
|
### Changes
|
||||||
|
|
||||||
Update `internal/adapters/inbound/httpapi/presenter/outlook.go`:
|
- Extend `internal/adapters/inbound/httpapi.Service` with the app service method selected in Stage 1.
|
||||||
|
- Add adapter-local clock injection near `alerts_endpoint.go`:
|
||||||
- Include `Discussions` when constructing the presented `model.WeatherOutlookRun`.
|
|
||||||
- Convert `WeatherOutlookDiscussion.UpdatedAt` using `inLocationTimePtr`.
|
|
||||||
- Deep-copy discussions so presenter output mutation cannot affect repository/app input.
|
|
||||||
- Remove `Headline`, `Summary`, and `Discussion` assignments from `copyOutlook`.
|
|
||||||
- Continue converting:
|
|
||||||
- run `AsOf`;
|
|
||||||
- run `IssuedAt`;
|
|
||||||
- outlook `ValidFrom`;
|
|
||||||
- outlook `ValidTo`;
|
|
||||||
- outlook `IssuedAt`;
|
|
||||||
- outlook `ExpiresAt`.
|
|
||||||
- Continue preserving geometry copy behavior.
|
|
||||||
- Continue returning `nil` for nil input.
|
|
||||||
|
|
||||||
Recommended helper:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func copyOutlookDiscussion(d model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion
|
var alertNow = time.Now
|
||||||
```
|
```
|
||||||
|
|
||||||
### Template Changes
|
- Update `/alerts/active` handler to call the new active-alert service method with `alertNow().UTC()`.
|
||||||
|
- Keep `bindQuery` as the binder so public query support remains `format` and `units` only.
|
||||||
|
- Keep the text template name `alerts_active.txt.tmpl`.
|
||||||
|
- Do not add timezone support in this cleanup. Alert timestamp presentation currently returns canonical model timestamps; timezone support would be a separate public API expansion.
|
||||||
|
|
||||||
Update `templates/outlooks_convective.txt.tmpl`:
|
### Tests
|
||||||
|
|
||||||
- Render run-level discussions from `.Data.Discussions`.
|
Update HTTP tests covering:
|
||||||
- Do not reference outlook-level `.Headline`, `.Summary`, or `.Discussion`.
|
|
||||||
- Keep no-data output when `.Data` is nil.
|
|
||||||
- Keep useful output when `.Data` exists and `.Data.Outlooks` is empty.
|
|
||||||
- Include discussion day/headline/summary/discussion fields in a readable, conditional way.
|
|
||||||
- Avoid printing zero timestamps or `<nil>` values for absent `updatedAt`.
|
|
||||||
|
|
||||||
### Presenter Tests
|
- route registration remains unchanged;
|
||||||
|
- handler passes `alertNow().UTC()` to the service;
|
||||||
Update `internal/adapters/inbound/httpapi/presenter/payload_test.go`:
|
- JSON success response renders filtered service output;
|
||||||
|
- text output renders zero active alerts as `Alerts: 0` when data is non-null;
|
||||||
- `OutlookRunPayload(nil, ...)` returns nil.
|
- `data: null` still renders no-data behavior;
|
||||||
- Output includes copied `Discussions`.
|
- `units=us` remains accepted and has no schema effect;
|
||||||
- Discussion `UpdatedAt` is converted to requested timezone.
|
- `precision`, `tz`, `TZ`, and unknown query params still return `400`.
|
||||||
- Mutating output discussions does not mutate input discussions.
|
|
||||||
- Mutating output geometry does not mutate input geometry.
|
|
||||||
- Outlook timestamps still convert to requested timezone.
|
|
||||||
- Polygon-level prose fields are not expected in output structs/tests.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/adapters/inbound/httpapi/presenter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 5: HTTP Endpoint Behavior And Tests
|
|
||||||
|
|
||||||
### Handler Behavior
|
|
||||||
|
|
||||||
Keep route registration and handler structure unchanged unless compile updates require mechanical edits.
|
|
||||||
|
|
||||||
Ensure endpoint responses reflect the updated presenter/app behavior:
|
|
||||||
|
|
||||||
- `/outlooks/convective` returns latest run with v2 shape.
|
|
||||||
- `/outlooks/convective/active` applies active filter and trims discussions.
|
|
||||||
- `/outlooks/convective/location` applies active local filter and trims discussions.
|
|
||||||
- `data: null` remains unchanged when no run exists.
|
|
||||||
- Filtered no-match response returns data object with empty outlook and discussion arrays.
|
|
||||||
|
|
||||||
### Endpoint Tests
|
|
||||||
|
|
||||||
Update `internal/adapters/inbound/httpapi/endpoints_test.go`:
|
|
||||||
|
|
||||||
- test fakes build `model.WeatherOutlookRun` with `Discussions`.
|
|
||||||
- JSON success asserts run-level `discussions` are present.
|
|
||||||
- JSON success asserts outlook polygons do not include polygon-level prose.
|
|
||||||
- XML success renders run-level discussions without errors.
|
|
||||||
- Text success renders run-level discussion content.
|
|
||||||
- Timezone test checks conversion of:
|
|
||||||
- run `asOf`;
|
|
||||||
- run `issuedAt`;
|
|
||||||
- outlook times;
|
|
||||||
- discussion `updatedAt`.
|
|
||||||
- Null data test remains unchanged.
|
|
||||||
- Filtered no-match test expects both `outlooks` and `discussions` empty.
|
|
||||||
- Query acceptance still covers `units`, `format`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation`.
|
|
||||||
- Query rejection still covers `precision`, unknown params, invalid day, invalid outlookType, invalid containsLocation, invalid timezone, conflicting `tz` / `TZ`, and `containsLocation` on `/location`.
|
|
||||||
- Active/location route tests continue using injectable `outlookNow`.
|
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
@@ -384,97 +157,57 @@ Update `internal/adapters/inbound/httpapi/endpoints_test.go`:
|
|||||||
go test ./internal/adapters/inbound/httpapi
|
go test ./internal/adapters/inbound/httpapi
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stage 6: Documentation Updates
|
## Stage 3: Presenter And Template Check
|
||||||
|
|
||||||
After code behavior is updated, update current-behavior documentation. Do not leave v2 behavior only in roadmap files.
|
### Changes
|
||||||
|
|
||||||
### Public Docs
|
- Keep `presenter.AlertsPayload` as a pass-through unless filtering requires defensive copy behavior at presentation time.
|
||||||
|
- Do not move active filtering into the presenter.
|
||||||
|
- Review `templates/alerts_active.txt.tmpl` and ensure it behaves correctly when `Data` is non-null and `Alerts` is empty.
|
||||||
|
- If needed, add an explicit text fixture/assertion rather than changing template wording broadly.
|
||||||
|
|
||||||
Update `docs/api.md`:
|
### Tests
|
||||||
|
|
||||||
- State outlook endpoints serve weatherfeeder `weather.outlook.v2` data.
|
Add or update presenter/template-sensitive tests only if current endpoint tests do not prove:
|
||||||
- Run fields include `discussions`.
|
|
||||||
- Add discussion field table:
|
|
||||||
- `day`;
|
|
||||||
- `headline`;
|
|
||||||
- `summary`;
|
|
||||||
- `discussion`;
|
|
||||||
- `updatedAt`.
|
|
||||||
- Remove polygon-level `headline`, `summary`, and `discussion` from outlook fields.
|
|
||||||
- State v2 outlooks are already location-filtered by weatherfeeder.
|
|
||||||
- State `containsLocation` is expected to be true for v2 outlooks.
|
|
||||||
- Explain `/outlooks/convective/location` remains an active local-outlook compatibility route under v2.
|
|
||||||
- State endpoint filters also filter `discussions` to days represented by retained outlooks.
|
|
||||||
- State current endpoints use latest-run semantics and do not accumulate historical active outlook rows.
|
|
||||||
- Update examples to show run-level `discussions`.
|
|
||||||
- Ensure examples use valid GeoJSON polygon or multipolygon, not a point, if demonstrating SPC geometry.
|
|
||||||
|
|
||||||
Update `README.md` only if its overview implies all-polygons or old response shape.
|
- nil data renders no-data text;
|
||||||
|
- non-null data with empty alerts renders an active-alert run with `Alerts: 0`;
|
||||||
|
- expired/canceled alerts do not appear in text output after service filtering.
|
||||||
|
|
||||||
### Integration/Internal Docs
|
### Verification
|
||||||
|
|
||||||
Update `docs/integrations/weatherfeeder-postgres.md`:
|
|
||||||
|
|
||||||
- Update dependency version.
|
|
||||||
- Table family for convective outlook includes `outlook_runs`, `outlooks`, and `outlook_discussions`.
|
|
||||||
- Latest run selection remains `as_of DESC, event_emitted_at DESC`.
|
|
||||||
- Child ordering includes:
|
|
||||||
- outlooks by `outlook_index ASC`;
|
|
||||||
- outlook discussions by `discussion_index ASC`.
|
|
||||||
- `outlook_runs` includes `discussion_count` if listed.
|
|
||||||
- `outlooks` no longer includes `headline`, `summary`, or `discussion`.
|
|
||||||
- Add `outlook_discussions` columns.
|
|
||||||
- State weatherapi assumes weatherfeeder's outlook v2 table reset has already been applied.
|
|
||||||
|
|
||||||
Update `docs/internal/postgres-repository.md`:
|
|
||||||
|
|
||||||
- `LatestConvectiveOutlookRun` loads parent, outlook children, and discussion children.
|
|
||||||
- Add outlook discussion child ordering.
|
|
||||||
|
|
||||||
Update `docs/internal/presenters.md`:
|
|
||||||
|
|
||||||
- Outlook presenter copies and timezone-converts run-level discussions.
|
|
||||||
- Outlook presenter no longer handles polygon-level prose.
|
|
||||||
|
|
||||||
Update `docs/internal/http-adapter.md`:
|
|
||||||
|
|
||||||
- Outlook filters trim discussions to retained outlook days.
|
|
||||||
- `/location` is retained for compatibility and active local-outlook behavior.
|
|
||||||
|
|
||||||
Update `docs/operations.md` and `docs/troubleshooting.md` only if they mention the old weatherfeeder table shape or should warn operators that weatherapi requires weatherfeeder v2 outlook tables.
|
|
||||||
|
|
||||||
### Roadmap Docs
|
|
||||||
|
|
||||||
After implementation is complete, replace or update `docs/roadmap/outlook.md` and `docs/roadmap/implementation.md` according to repository convention:
|
|
||||||
|
|
||||||
- Either mark the roadmap complete and point to current docs.
|
|
||||||
- Or move any remaining deferred ideas to a future roadmap file.
|
|
||||||
|
|
||||||
Do not leave roadmap files claiming unimplemented work after the implementation has shipped.
|
|
||||||
|
|
||||||
### Documentation Checks
|
|
||||||
|
|
||||||
Run searches for stale outlook v1/current behavior:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rg "weather\.outlook\.v1|outlooks\.headline|outlooks\.summary|outlooks\.discussion|polygon-level|all stored outlook|all polygons|headline, summary, discussion" docs README.md internal templates
|
go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||||
```
|
```
|
||||||
|
|
||||||
Allowed matches:
|
## Stage 4: Documentation Updates
|
||||||
|
|
||||||
- explicit historical/legacy notes, if any;
|
### Changes
|
||||||
- unrelated alert or forecast discussion prose fields;
|
|
||||||
- roadmap files that are intentionally historical.
|
|
||||||
|
|
||||||
## Stage 7: Full Verification
|
Update current-behavior docs after implementation is complete:
|
||||||
|
|
||||||
|
- `docs/api.md`:
|
||||||
|
- clarify that `/alerts/active` returns the latest alert run filtered to alerts active at request time;
|
||||||
|
- document that no current active alerts returns a run with `alerts: []` when a latest run exists;
|
||||||
|
- keep `data: null` limited to no stored alert run;
|
||||||
|
- keep supported query params as `format` and `units`.
|
||||||
|
- `README.md` if endpoint summaries mention active alerts.
|
||||||
|
- `docs/internal/postgres-repository.md`:
|
||||||
|
- clarify that `LatestAlertRun` reads the latest stored snapshot and active filtering is performed in the app service.
|
||||||
|
- `docs/policy/architecture.md` only if its latest-resource wording needs to distinguish latest snapshots from request-time derived active views.
|
||||||
|
|
||||||
|
Do not document this unimplemented behavior outside `docs/roadmap/` before the code change lands.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
No doc-specific tests are required unless existing docs consistency tests cover endpoint summaries.
|
||||||
|
|
||||||
|
## Stage 5: Final Verification
|
||||||
|
|
||||||
Run focused tests:
|
Run focused tests:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/app
|
go test ./internal/app ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||||
go test ./internal/adapters/outbound/postgres
|
|
||||||
go test ./internal/adapters/inbound/httpapi/presenter
|
|
||||||
go test ./internal/adapters/inbound/httpapi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the full suite:
|
Run the full suite:
|
||||||
@@ -483,42 +216,32 @@ Run the full suite:
|
|||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional build check:
|
Manual smoke checks after deployment:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go build ./cmd/weatherapi
|
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=json'
|
||||||
|
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=text'
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual verification against a database populated by weatherfeeder outlook v2 tables:
|
Expected behavior when all alerts in the latest stored run are expired:
|
||||||
|
|
||||||
```http
|
- JSON returns a non-null `data` object with `alerts: []`.
|
||||||
GET /outlooks/convective
|
- Text output shows the run metadata and `Alerts: 0`.
|
||||||
GET /outlooks/convective/active
|
|
||||||
GET /outlooks/convective/location
|
|
||||||
GET /outlooks/convective?day=1
|
|
||||||
GET /outlooks/convective?outlookType=tornado
|
|
||||||
GET /outlooks/convective?containsLocation=false
|
|
||||||
GET /outlooks/convective?format=text&tz=America/Chicago
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected manual results:
|
## Guardrails
|
||||||
|
|
||||||
- Latest run includes run-level `discussions`.
|
- Do not change weatherfeeder ingestion behavior in this cleanup.
|
||||||
- Active route filters outlooks and discussions together.
|
- Do not alter Postgres tables or SQL filtering unless a later performance issue justifies it.
|
||||||
- Location route returns active local outlooks and remains valid.
|
- Do not make `weatherapi` poll NWS or any upstream provider.
|
||||||
- `containsLocation=false` returns an empty run for v2 data.
|
- Do not rename `/alerts/active`.
|
||||||
- Text format renders discussions without template errors.
|
- Do not add alert history endpoints in this cleanup.
|
||||||
- XML format renders without errors.
|
- Do not introduce generic filtering frameworks; use small app-local helpers.
|
||||||
- No endpoint queries historical active rows across older runs.
|
- Do not move route query validation into the app layer.
|
||||||
|
- Do not move active filtering into presenters or text templates.
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|
||||||
- A weatherfeeder release containing outlook v2 exists before final implementation is committed.
|
- `weatherfeeder` persists alert snapshots that were active at ingestion time, but `weatherapi` must still enforce request-time active semantics.
|
||||||
- Operators have reset/recreated weatherfeeder outlook tables according to weatherfeeder transition docs.
|
- Cancellation messages are operational metadata, not active hazards, and should not be returned by `/alerts/active`.
|
||||||
- Existing weatherapi route names remain stable for external consumers.
|
- Missing `expires` should not suppress an alert unless future real-world data shows this creates stale records.
|
||||||
- Keeping `containsLocation` query support on non-location routes is useful backward compatibility even though v2 data should always be local.
|
- Time comparisons should use UTC instants; timezone presentation is not part of this cleanup.
|
||||||
- Latest-run semantics are correct for current convective outlook endpoints.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None. This plan fixes the route, dependency, storage, filtering, presentation, and documentation decisions required to support weatherfeeder outlook v2 while preserving weatherapi's existing public route family.
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
# Weatherfeeder Outlook V2 Support
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Implemented in current behavior docs and code. The authoritative implemented
|
|
||||||
contracts now live in:
|
|
||||||
|
|
||||||
- [`docs/api.md`](../api.md) for public outlook routes, query parameters, and
|
|
||||||
response fields;
|
|
||||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
|
||||||
for weatherfeeder-owned table assumptions;
|
|
||||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md),
|
|
||||||
[`docs/internal/http-adapter.md`](../internal/http-adapter.md), and
|
|
||||||
[`docs/internal/presenters.md`](../internal/presenters.md) for internal
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
`docs/roadmap/implementation.md` remains as the implementation checklist and
|
|
||||||
verification record for this compatibility update.
|
|
||||||
|
|
||||||
## Delivered Behavior
|
|
||||||
|
|
||||||
- Existing public routes remain available:
|
|
||||||
- `GET /outlooks/convective`
|
|
||||||
- `GET /outlooks/convective/active`
|
|
||||||
- `GET /outlooks/convective/location`
|
|
||||||
- The Postgres repository reads weatherfeeder outlook v2 tables:
|
|
||||||
`outlook_runs`, `outlooks`, and `outlook_discussions`.
|
|
||||||
- Outlook polygons no longer include prose fields.
|
|
||||||
- Run-level `discussions` are loaded, copied, presented, and timezone-converted.
|
|
||||||
- Application filtering trims `discussions` to days represented by retained
|
|
||||||
outlooks.
|
|
||||||
- Missing latest data still returns `data: null`.
|
|
||||||
- Filtered no-match responses return a run object with `outlooks: []` and
|
|
||||||
`discussions: []`.
|
|
||||||
- `weatherapi` remains read-only and does not create, migrate, or repair
|
|
||||||
weatherfeeder tables.
|
|
||||||
@@ -4,6 +4,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/render"
|
"gitea.maximumdirect.net/ejr/feedapi/render"
|
||||||
@@ -11,12 +12,14 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var alertNow = time.Now
|
||||||
|
|
||||||
func alertsDefinition(svc Service) endpoint.Definition {
|
func alertsDefinition(svc Service) endpoint.Definition {
|
||||||
return endpoint.GET(
|
return endpoint.GET(
|
||||||
"/alerts/active",
|
"/alerts/active",
|
||||||
bindQuery,
|
bindQuery,
|
||||||
func(ctx context.Context, req queryRequest) (any, error) {
|
func(ctx context.Context, req queryRequest) (any, error) {
|
||||||
run, err := svc.LatestAlertRun(ctx)
|
run, err := svc.LatestActiveAlertRun(ctx, alertNow().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type fakeService struct {
|
|||||||
weatherStoryRun *model.WeatherStoryRun
|
weatherStoryRun *model.WeatherStoryRun
|
||||||
weatherStory *model.WeatherStory
|
weatherStory *model.WeatherStory
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
|
activeAlertAt []time.Time
|
||||||
outlookRun *model.WeatherOutlookRun
|
outlookRun *model.WeatherOutlookRun
|
||||||
outlookFilters []app.OutlookFilter
|
outlookFilters []app.OutlookFilter
|
||||||
conditions *app.CurrentConditions
|
conditions *app.CurrentConditions
|
||||||
@@ -64,6 +65,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e
|
|||||||
return s.alerts, s.err
|
return s.alerts, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *fakeService) LatestActiveAlertRun(_ context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
|
||||||
|
s.activeAlertAt = append(s.activeAlertAt, activeAt)
|
||||||
|
return s.alerts, s.err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
|
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||||
s.outlookFilters = append(s.outlookFilters, filter)
|
s.outlookFilters = append(s.outlookFilters, filter)
|
||||||
return s.outlookRun, s.err
|
return s.outlookRun, s.err
|
||||||
@@ -73,6 +79,46 @@ func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions
|
|||||||
return s.conditions, s.err
|
return s.conditions, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type alertRepository struct {
|
||||||
|
alerts *model.WeatherAlertRun
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
|
return r.alerts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) CurrentConditions(context.Context, int) (*app.CurrentConditions, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{}, "/observations")
|
h := newHandler(t, &fakeService{}, "/observations")
|
||||||
|
|
||||||
@@ -315,6 +361,153 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertsRouteRegistered(t *testing.T) {
|
||||||
|
def := definitionForPath(t, Definitions(&fakeService{}), "/alerts/active")
|
||||||
|
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
||||||
|
t.Fatalf("expected GET definition, got %+v", def.Methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsHandlerPassesCurrentUTCInstant(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("LOCAL", -5*60*60))
|
||||||
|
setAlertNowForTest(t, now)
|
||||||
|
svc := &fakeService{
|
||||||
|
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
||||||
|
}
|
||||||
|
h := newHandler(t, svc, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if len(svc.activeAlertAt) != 1 {
|
||||||
|
t.Fatalf("expected one active-alert service call, got %d", len(svc.activeAlertAt))
|
||||||
|
}
|
||||||
|
want := now.UTC()
|
||||||
|
if !svc.activeAlertAt[0].Equal(want) {
|
||||||
|
t.Fatalf("expected activeAt %v, got %v", want, svc.activeAlertAt[0])
|
||||||
|
}
|
||||||
|
if svc.activeAlertAt[0].Location() != time.UTC {
|
||||||
|
t.Fatalf("expected UTC location, got %v", svc.activeAlertAt[0].Location())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{}, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Data *json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode envelope: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Data != nil {
|
||||||
|
t.Fatalf("expected data null, got %s", string(*payload.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsTextRendersNoData(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{}, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "No active alerts data available.") {
|
||||||
|
t.Fatalf("expected no-data alert text, got %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsTextRendersEmptyActiveSet(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{
|
||||||
|
alerts: &model.WeatherAlertRun{
|
||||||
|
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||||
|
Alerts: []model.WeatherAlert{},
|
||||||
|
},
|
||||||
|
}, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "Alerts: 0") {
|
||||||
|
t.Fatalf("expected empty alert count in text body, got %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
||||||
|
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
|
setAlertNowForTest(t, activeAt)
|
||||||
|
effective := activeAt.Add(-1 * time.Hour)
|
||||||
|
activeExpires := activeAt.Add(1 * time.Hour)
|
||||||
|
expiredAtBoundary := activeAt
|
||||||
|
repo := &alertRepository{
|
||||||
|
alerts: &model.WeatherAlertRun{
|
||||||
|
AsOf: activeAt,
|
||||||
|
Alerts: []model.WeatherAlert{
|
||||||
|
{
|
||||||
|
ID: "active-alert",
|
||||||
|
Headline: "Active warning",
|
||||||
|
MessageType: "Alert",
|
||||||
|
Effective: &effective,
|
||||||
|
Expires: &activeExpires,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "expired-alert",
|
||||||
|
Headline: "Expired warning",
|
||||||
|
MessageType: "Alert",
|
||||||
|
Effective: &effective,
|
||||||
|
Expires: &expiredAtBoundary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "canceled-alert",
|
||||||
|
Headline: "Canceled warning",
|
||||||
|
MessageType: " cancel ",
|
||||||
|
Effective: &effective,
|
||||||
|
Expires: &activeExpires,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := newHandler(t, app.NewService(repo), "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("expected %q in text body, got %q", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notWant := range []string{"expired-alert", "Expired warning", "canceled-alert", "Canceled warning"} {
|
||||||
|
if strings.Contains(body, notWant) {
|
||||||
|
t.Fatalf("did not expect %q in text body, got %q", notWant, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
|
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{
|
h := newHandler(t, &fakeService{
|
||||||
observation: &model.WeatherObservation{
|
observation: &model.WeatherObservation{
|
||||||
@@ -1265,17 +1458,24 @@ func TestPrecisionValidationRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
|
func TestAlertsRejectUnsupportedQueryParameters(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{
|
h := newHandler(t, &fakeService{
|
||||||
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
||||||
}, "/alerts/active")
|
}, "/alerts/active")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
for _, rawURL := range []string{
|
||||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil)
|
"/alerts/active?precision=1",
|
||||||
h.ServeHTTP(w, req)
|
"/alerts/active?tz=CDT",
|
||||||
|
"/alerts/active?TZ=CDT",
|
||||||
|
"/alerts/active?bogus=1",
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2263,7 +2463,7 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
||||||
"weatherstories.txt.tmpl": "Weather Stories",
|
"weatherstories.txt.tmpl": "Weather Stories",
|
||||||
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||||
"alerts_active.txt.tmpl": "Alerts text",
|
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
|
||||||
"conditions_current.txt.tmpl": "Conditions text",
|
"conditions_current.txt.tmpl": "Conditions text",
|
||||||
} {
|
} {
|
||||||
tmpl, err := template.New(name).Parse(body)
|
tmpl, err := template.New(name).Parse(body)
|
||||||
@@ -2297,6 +2497,13 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
|
|||||||
t.Cleanup(func() { outlookNow = original })
|
t.Cleanup(func() { outlookNow = original })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setAlertNowForTest(t *testing.T, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
original := alertNow
|
||||||
|
alertNow = func() time.Time { return now }
|
||||||
|
t.Cleanup(func() { alertNow = original })
|
||||||
|
}
|
||||||
|
|
||||||
func testOutlookRun() *model.WeatherOutlookRun {
|
func testOutlookRun() *model.WeatherOutlookRun {
|
||||||
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
||||||
|
|||||||
@@ -337,6 +337,22 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertsPayloadPassThrough(t *testing.T) {
|
||||||
|
run := &model.WeatherAlertRun{
|
||||||
|
LocationID: "stl",
|
||||||
|
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||||
|
Alerts: []model.WeatherAlert{{
|
||||||
|
ID: "alert-1",
|
||||||
|
Headline: "Storm warning",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := AlertsPayload(run, UnitsUS)
|
||||||
|
if payload != run {
|
||||||
|
t.Fatalf("expected alerts payload to pass through input run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
||||||
conditions := &app.CurrentConditions{
|
conditions := &app.CurrentConditions{
|
||||||
TemperatureC: float64Ptr(20),
|
TemperatureC: float64Ptr(20),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
@@ -18,6 +19,7 @@ type Service interface {
|
|||||||
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
||||||
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
||||||
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||||
|
LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)
|
||||||
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
|
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
|
||||||
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, e
|
|||||||
return s.repo.LatestAlertRun(ctx)
|
return s.repo.LatestAlertRun(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
|
||||||
|
run, err := s.repo.LatestAlertRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := cloneAlertRun(run)
|
||||||
|
alerts := out.Alerts[:0]
|
||||||
|
for _, alert := range out.Alerts {
|
||||||
|
if isActiveAlert(alert, activeAt) {
|
||||||
|
alerts = append(alerts, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Alerts = alerts
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
|
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||||
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
|
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,6 +133,53 @@ func normalizeOutlookType(value string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(value))
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneAlertRun(run *model.WeatherAlertRun) *model.WeatherAlertRun {
|
||||||
|
out := *run
|
||||||
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
out.Longitude = copyFloat64(run.Longitude)
|
||||||
|
if run.Alerts != nil {
|
||||||
|
out.Alerts = make([]model.WeatherAlert, len(run.Alerts))
|
||||||
|
for i := range run.Alerts {
|
||||||
|
out.Alerts[i] = cloneAlert(run.Alerts[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAlert(alert model.WeatherAlert) model.WeatherAlert {
|
||||||
|
out := alert
|
||||||
|
out.Sent = copyTime(alert.Sent)
|
||||||
|
out.Effective = copyTime(alert.Effective)
|
||||||
|
out.Onset = copyTime(alert.Onset)
|
||||||
|
out.Expires = copyTime(alert.Expires)
|
||||||
|
if alert.References != nil {
|
||||||
|
out.References = make([]model.AlertReference, len(alert.References))
|
||||||
|
for i := range alert.References {
|
||||||
|
out.References[i] = cloneAlertReference(alert.References[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAlertReference(ref model.AlertReference) model.AlertReference {
|
||||||
|
out := ref
|
||||||
|
out.Sent = copyTime(ref.Sent)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(alert.MessageType), "Cancel") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if alert.Expires != nil && !activeAt.Before(*alert.Expires) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||||
out := *run
|
out := *run
|
||||||
out.Latitude = copyFloat64(run.Latitude)
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type fakeRepository struct {
|
|||||||
err error
|
err error
|
||||||
|
|
||||||
currentConditionsWindow int
|
currentConditionsWindow int
|
||||||
|
alertRunCalls int
|
||||||
outlookRunCalls int
|
outlookRunCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStor
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
|
r.alertRunCalls++
|
||||||
return r.alerts, r.err
|
return r.alerts, r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +120,137 @@ func TestServiceDelegatesAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
repo := &fakeRepository{alerts: testAlertRun()}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if repo.alertRunCalls != 1 {
|
||||||
|
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
||||||
|
repo := &fakeRepository{}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run != nil {
|
||||||
|
t.Fatalf("expected nil alert run, got %+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
|
||||||
|
want := errors.New("alert read failed")
|
||||||
|
repo := &fakeRepository{err: want}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("expected error %v, got %v", want, err)
|
||||||
|
}
|
||||||
|
if run != nil {
|
||||||
|
t.Fatalf("expected nil alert run on error, got %+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
|
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
||||||
|
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||||
|
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14)),
|
||||||
|
})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
t.Fatal("expected alert run")
|
||||||
|
}
|
||||||
|
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(10)) {
|
||||||
|
t.Fatalf("unexpected run metadata: %+v", run)
|
||||||
|
}
|
||||||
|
if run.Latitude == nil || *run.Latitude != 38.62 {
|
||||||
|
t.Fatalf("unexpected latitude: %v", run.Latitude)
|
||||||
|
}
|
||||||
|
if run.Longitude == nil || *run.Longitude != -90.2 {
|
||||||
|
t.Fatalf("unexpected longitude: %v", run.Longitude)
|
||||||
|
}
|
||||||
|
if run.Alerts == nil {
|
||||||
|
t.Fatal("expected empty alert slice, got nil")
|
||||||
|
}
|
||||||
|
if len(run.Alerts) != 0 {
|
||||||
|
t.Fatalf("expected no alerts, got %+v", run.Alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
||||||
|
original := testAlertRun()
|
||||||
|
repo := &fakeRepository{alerts: original}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(run.Alerts) == 0 {
|
||||||
|
t.Fatal("expected active alerts")
|
||||||
|
}
|
||||||
|
|
||||||
|
*run.Latitude = 99
|
||||||
|
*run.Longitude = -99
|
||||||
|
*run.Alerts[0].Sent = testTime(1)
|
||||||
|
*run.Alerts[0].Effective = testTime(2)
|
||||||
|
*run.Alerts[0].Onset = testTime(3)
|
||||||
|
*run.Alerts[0].Expires = testTime(4)
|
||||||
|
*run.Alerts[0].References[0].Sent = testTime(5)
|
||||||
|
run.Alerts[0].ID = "changed"
|
||||||
|
run.Alerts[0].References[0].ID = "changed"
|
||||||
|
run.Alerts = run.Alerts[:1]
|
||||||
|
|
||||||
|
if *original.Latitude != 38.62 {
|
||||||
|
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
||||||
|
}
|
||||||
|
if *original.Longitude != -90.2 {
|
||||||
|
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].ID != "current" {
|
||||||
|
t.Fatalf("expected original alert ID unchanged, got %q", original.Alerts[0].ID)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Sent == nil || !original.Alerts[0].Sent.Equal(testTime(9)) {
|
||||||
|
t.Fatalf("expected original sent unchanged, got %v", original.Alerts[0].Sent)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Effective == nil || !original.Alerts[0].Effective.Equal(testTime(10)) {
|
||||||
|
t.Fatalf("expected original effective unchanged, got %v", original.Alerts[0].Effective)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
||||||
|
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) {
|
||||||
|
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].References[0].ID != "ref-current" {
|
||||||
|
t.Fatalf("expected original reference ID unchanged, got %q", original.Alerts[0].References[0].ID)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
||||||
|
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
||||||
|
}
|
||||||
|
if len(original.Alerts) != 8 {
|
||||||
|
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
@@ -402,6 +535,67 @@ func TestServicePropagatesErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAlertRun() *model.WeatherAlertRun {
|
||||||
|
return testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
|
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||||
|
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
||||||
|
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)),
|
||||||
|
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||||
|
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)),
|
||||||
|
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)),
|
||||||
|
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil),
|
||||||
|
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun {
|
||||||
|
latitude := 38.62
|
||||||
|
longitude := -90.2
|
||||||
|
return &model.WeatherAlertRun{
|
||||||
|
LocationID: "stl",
|
||||||
|
LocationName: "St. Louis",
|
||||||
|
AsOf: testTime(10),
|
||||||
|
Latitude: &latitude,
|
||||||
|
Longitude: &longitude,
|
||||||
|
Alerts: alerts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, expires *time.Time) model.WeatherAlert {
|
||||||
|
refSent := testTime(8)
|
||||||
|
return model.WeatherAlert{
|
||||||
|
ID: id,
|
||||||
|
Event: "Thunderstorm Warning",
|
||||||
|
Headline: "Storm headline",
|
||||||
|
Severity: "Severe",
|
||||||
|
Urgency: "Immediate",
|
||||||
|
Certainty: "Likely",
|
||||||
|
Status: "Actual",
|
||||||
|
MessageType: messageType,
|
||||||
|
Category: "Met",
|
||||||
|
Response: "Shelter",
|
||||||
|
Description: "Storm description",
|
||||||
|
Instruction: "Take shelter",
|
||||||
|
Sent: sent,
|
||||||
|
Effective: effective,
|
||||||
|
Onset: onset,
|
||||||
|
Expires: expires,
|
||||||
|
AreaDescription: "St. Louis City",
|
||||||
|
SenderName: "NWS St. Louis",
|
||||||
|
References: []model.AlertReference{{
|
||||||
|
ID: "ref-" + id,
|
||||||
|
Identifier: "identifier-" + id,
|
||||||
|
Sender: "sender-" + id,
|
||||||
|
Sent: &refSent,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimePtr(hour int) *time.Time {
|
||||||
|
value := testTime(hour)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func testOutlookRun() *model.WeatherOutlookRun {
|
func testOutlookRun() *model.WeatherOutlookRun {
|
||||||
latitude := 38.62
|
latitude := 38.62
|
||||||
longitude := -90.2
|
longitude := -90.2
|
||||||
@@ -493,3 +687,18 @@ func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertAlertIDs(t *testing.T, run *model.WeatherAlertRun, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
if run == nil {
|
||||||
|
t.Fatal("expected alert run")
|
||||||
|
}
|
||||||
|
if len(run.Alerts) != len(want) {
|
||||||
|
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if run.Alerts[i].ID != want[i] {
|
||||||
|
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user