Compare commits
10 Commits
v0.4.0
...
90abc536dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 90abc536dd | |||
| 370d7c0928 | |||
| b556052966 | |||
| c7760742e6 | |||
| beeaeeaecb | |||
| 1f2459923f | |||
| fa1b2cb390 | |||
| 82dd304f10 | |||
| 14c6954296 | |||
| 0135eb1153 |
67
docs/api.md
67
docs/api.md
@@ -230,19 +230,22 @@ GET /outlooks/convective/location
|
||||
```
|
||||
|
||||
Returns the latest SPC convective outlook run reconstructed from
|
||||
weatherfeeder-owned Postgres tables.
|
||||
weatherfeeder-owned `weather.outlook.v2` Postgres tables.
|
||||
|
||||
Route behavior:
|
||||
|
||||
- `/outlooks/convective` returns the latest run with all stored outlook
|
||||
polygons unless user filters are supplied.
|
||||
- `/outlooks/convective` returns the latest run with stored location-filtered
|
||||
outlook polygons unless user filters are supplied.
|
||||
- `/outlooks/convective/active` adds an active-time filter using the server's
|
||||
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
|
||||
- `/outlooks/convective/location` adds the same active-time filter and
|
||||
`containsLocation=true`.
|
||||
`containsLocation=true`. It remains as an active local-outlook compatibility
|
||||
route under the v2 weatherfeeder contract.
|
||||
|
||||
When no latest run exists, `data` is null. When a run exists but filters match
|
||||
no outlooks, `data` remains an object and `outlooks` is an empty array.
|
||||
no outlooks, `data` remains an object and `outlooks` and `discussions` are
|
||||
empty arrays. Outlook endpoints use latest-run semantics and do not accumulate
|
||||
historical active outlooks across older runs.
|
||||
|
||||
Query parameters:
|
||||
|
||||
@@ -253,9 +256,12 @@ Query parameters:
|
||||
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
|
||||
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
|
||||
|
||||
`outlookType` values are normalized case-insensitively. `containsLocation` is
|
||||
rejected on `/outlooks/convective/location` because that route always applies
|
||||
`containsLocation=true`. `precision` and unknown parameters are rejected.
|
||||
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
||||
outlooks are already filtered for the configured location, so
|
||||
`containsLocation` is expected to be true for stored v2 outlooks.
|
||||
`containsLocation` is rejected on `/outlooks/convective/location` because that
|
||||
route always applies `containsLocation=true`. `precision` and unknown
|
||||
parameters are rejected.
|
||||
|
||||
Run `data` fields:
|
||||
|
||||
@@ -266,6 +272,7 @@ Run `data` fields:
|
||||
| `asOf` | RFC3339 datetime | required when `data` is not null |
|
||||
| `issuedAt` | RFC3339 datetime | optional |
|
||||
| `outlooks` | array | ordered outlook polygons, possibly empty |
|
||||
| `discussions` | array | ordered day-level discussions, possibly empty |
|
||||
|
||||
Outlook fields:
|
||||
|
||||
@@ -273,17 +280,27 @@ Outlook fields:
|
||||
| --- | --- | --- |
|
||||
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
|
||||
| `day` | integer | SPC outlook day |
|
||||
| `labelText`, `forecaster`, `headline`, `summary`, `discussion` | string | optional |
|
||||
| `labelText`, `forecaster` | string | optional |
|
||||
| `severityRank` | integer | optional |
|
||||
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
|
||||
| `sourceUrl`, `imageUrl` | string | optional |
|
||||
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
|
||||
| `geometry` | GeoJSON | stored outlook geometry |
|
||||
|
||||
Discussion fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `day` | integer | SPC outlook day |
|
||||
| `headline`, `summary`, `discussion` | string | optional |
|
||||
| `updatedAt` | RFC3339 datetime | optional |
|
||||
|
||||
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
|
||||
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
|
||||
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering
|
||||
compares instants and is not changed by the presentation timezone.
|
||||
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
|
||||
`updatedAt`. Active filtering compares instants and is not changed by the
|
||||
presentation timezone. Endpoint filters also filter `discussions` to days
|
||||
represented by retained outlooks.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -328,6 +345,15 @@ Example JSON response:
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"day": 1,
|
||||
"headline": "Severe storms possible",
|
||||
"summary": "Scattered severe storms are possible.",
|
||||
"discussion": "SPC discussion text.",
|
||||
"updatedAt": "2026-06-11T17:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -354,10 +380,25 @@ Example location-filtered JSON response:
|
||||
"expiresAt": "2026-06-12T12:00:00Z",
|
||||
"containsLocation": true,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-90.2, 38.6]
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-90.6, 38.4],
|
||||
[-90.0, 38.4],
|
||||
[-90.0, 38.8],
|
||||
[-90.6, 38.4]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"day": 1,
|
||||
"headline": "Tornado risk near the configured location",
|
||||
"discussion": "SPC tornado outlook discussion text.",
|
||||
"updatedAt": "2026-06-11T17:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
`go.mod` depends on:
|
||||
|
||||
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0`
|
||||
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0`
|
||||
|
||||
The repository code also depends on weatherfeeder canonical model types. Table
|
||||
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
|
||||
For convective outlooks, `weatherapi` assumes weatherfeeder's
|
||||
`weather.outlook.v2` table reset has already been applied.
|
||||
|
||||
## Boundary
|
||||
|
||||
@@ -37,7 +39,7 @@ Postgres owns persistence, backup, restore, and availability.
|
||||
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
|
||||
| Weather story run | `weather_story_runs`, `weather_stories` |
|
||||
| Latest weather story | `weather_stories` |
|
||||
| Convective outlook run | `outlook_runs`, `outlooks` |
|
||||
| Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
|
||||
|
||||
## Latest Row Selection
|
||||
|
||||
@@ -68,7 +70,8 @@ Child rows are loaded separately and attached in stored order:
|
||||
- forecast periods: `period_index ASC`;
|
||||
- forecast discussion key messages: `message_index ASC`;
|
||||
- weather stories for a run: `story_index ASC`;
|
||||
- outlooks for a run: `outlook_index ASC`.
|
||||
- outlooks for a run: `outlook_index ASC`;
|
||||
- outlook discussions for a run: `discussion_index ASC`.
|
||||
|
||||
## Columns Read
|
||||
|
||||
@@ -160,12 +163,17 @@ routes.
|
||||
|
||||
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
|
||||
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
|
||||
`expires_at`, `forecaster`, `headline`, `summary`, `discussion`, `source_url`,
|
||||
`image_url`, `contains_location`, `geometry_json`, and `run_event_id`.
|
||||
`expires_at`, `forecaster`, `source_url`, `image_url`, `contains_location`,
|
||||
`geometry_json`, and `run_event_id`.
|
||||
|
||||
`geometry_json` is copied into response GeoJSON without parsing or
|
||||
reserializing. It must contain valid JSON.
|
||||
|
||||
### `outlook_discussions`
|
||||
|
||||
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
|
||||
and `run_event_id`.
|
||||
|
||||
## Nullability and Time Assumptions
|
||||
|
||||
The repository scans nullable columns with `sql.Null*` types and maps them to
|
||||
|
||||
@@ -144,6 +144,11 @@ application service:
|
||||
The package variable `outlookNow` exists so endpoint tests can make active and
|
||||
location filtering deterministic.
|
||||
|
||||
The application service returns filtered outlook copies and trims run-level
|
||||
discussions to days represented by retained outlooks.
|
||||
`/outlooks/convective/location` is retained for compatibility and active
|
||||
local-outlook behavior under the weatherfeeder outlook v2 contract.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Binder failures become feedapi invalid-parameter responses. Handler service
|
||||
|
||||
@@ -90,7 +90,7 @@ successful responses with `data: null`.
|
||||
`weather_stories`.
|
||||
- `LatestWeatherStory`: latest individual row from `weather_stories`.
|
||||
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
|
||||
`outlooks`.
|
||||
`outlooks` and `outlook_discussions`.
|
||||
|
||||
Latest parent rows are selected by descending weather timestamp and
|
||||
`event_emitted_at` where that tie-breaker is available in the query.
|
||||
@@ -105,7 +105,8 @@ Child queries preserve stored order:
|
||||
- forecast periods by `period_index`;
|
||||
- discussion key messages by `message_index`;
|
||||
- weather stories by `story_index`;
|
||||
- outlooks by `outlook_index`.
|
||||
- outlooks by `outlook_index`;
|
||||
- outlook discussions by `discussion_index`.
|
||||
|
||||
Alert references are attached after both alert and reference rows are loaded.
|
||||
References are grouped by alert index and attached to their corresponding alert.
|
||||
|
||||
@@ -118,9 +118,11 @@ response envelope so renderers can produce `data: null`.
|
||||
- Discussions: full or focused payload shapes, section copy, key-message copy,
|
||||
timezone conversion.
|
||||
- Weather stories: run/story copy and timezone conversion.
|
||||
- Convective outlooks: canonical model copy, pointer and geometry copy, and
|
||||
timezone conversion. `units` is accepted by routes but ignored by the
|
||||
presenter because outlook fields are not unit-bearing.
|
||||
- Convective outlooks: canonical model copy, pointer and geometry copy,
|
||||
run-level discussion copy, and timezone conversion for run, outlook, and
|
||||
discussion timestamps. Outlook polygon prose is not handled by the presenter;
|
||||
prose is carried by run-level discussions. `units` is accepted by routes but
|
||||
ignored by the presenter because outlook fields are not unit-bearing.
|
||||
|
||||
## Templates
|
||||
|
||||
|
||||
@@ -91,8 +91,13 @@ build.
|
||||
## Database Dependency
|
||||
|
||||
`weatherapi` expects weatherfeeder-compatible tables for observations, current
|
||||
conditions aggregation, active alerts, forecasts, forecast discussions, and
|
||||
weather stories. It only reads those tables.
|
||||
conditions aggregation, active alerts, forecasts, forecast discussions, weather
|
||||
stories, and convective outlooks. It only reads those tables.
|
||||
|
||||
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
|
||||
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
|
||||
operators reset or recreate outlook tables during a weatherfeeder upgrade,
|
||||
complete that weatherfeeder-side migration before starting `weatherapi`.
|
||||
|
||||
Operational ownership is split:
|
||||
|
||||
@@ -113,7 +118,7 @@ template named by each endpoint, with templates stored under `templates.base_dir
|
||||
|
||||
The repository includes templates for all implemented endpoint families:
|
||||
observations, current conditions, active alerts, hourly forecasts, narrative
|
||||
forecasts, forecast discussions, and weather stories.
|
||||
forecasts, forecast discussions, weather stories, and convective outlooks.
|
||||
|
||||
If text rendering fails or returns an unsupported-format error, verify:
|
||||
|
||||
|
||||
@@ -1,14 +1,230 @@
|
||||
# SPC Convective Outlook API Roadmap
|
||||
# Active Alert Filtering Cleanup
|
||||
|
||||
There are no active roadmap items for the SPC convective outlook API.
|
||||
## Summary
|
||||
|
||||
Current behavior is documented in:
|
||||
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
||||
|
||||
- [`docs/api.md`](../api.md)
|
||||
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
|
||||
- [`docs/internal/presenters.md`](../internal/presenters.md)
|
||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
|
||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
||||
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.
|
||||
|
||||
Future changes to convective outlook behavior should be proposed in a new
|
||||
roadmap entry before implementation.
|
||||
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.
|
||||
|
||||
## Current Behavior To Correct
|
||||
|
||||
- `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`.
|
||||
|
||||
## Target Behavior
|
||||
|
||||
- `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
|
||||
|
||||
- Keep `app.Repository.LatestAlertRun(ctx)` unchanged.
|
||||
- Add an app-level alert read use case, either:
|
||||
- `LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)`, or
|
||||
- `LatestAlerts(ctx context.Context, filter AlertFilter) (*model.WeatherAlertRun, error)` with `AlertFilter.ActiveAt`.
|
||||
- Prefer the first option unless another alert route is being added at the same time; it is narrower and clearer for the existing API.
|
||||
- Implement the new service method by:
|
||||
- reading `s.repo.LatestAlertRun(ctx)`;
|
||||
- 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)`.
|
||||
|
||||
### Clone Requirements
|
||||
|
||||
The clone must protect repository-returned data from service mutation:
|
||||
|
||||
- allocate a new `Alerts` slice;
|
||||
- allocate new `References` slices for each alert;
|
||||
- copy pointer time fields only if the model uses pointer fields for alert timestamps;
|
||||
- preserve string, boolean, enum, and scalar values exactly.
|
||||
|
||||
### Tests
|
||||
|
||||
Add app tests covering:
|
||||
|
||||
- repository delegation and error propagation;
|
||||
- `nil, nil` when the repository has no latest alert run;
|
||||
- expired alerts are removed when `activeAt >= expires`;
|
||||
- alerts expiring exactly at `activeAt` are inactive;
|
||||
- alerts effective exactly at `activeAt` are active;
|
||||
- future-effective alerts are inactive;
|
||||
- missing `effective` does not make an otherwise valid alert inactive;
|
||||
- missing `expires` does not make an otherwise valid alert inactive;
|
||||
- `messageType=Cancel` is excluded case-insensitively;
|
||||
- alert order is preserved;
|
||||
- run metadata is preserved when all alerts are filtered out;
|
||||
- filtering does not mutate the repository-owned run, alerts, references, or timestamp pointers.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./internal/app
|
||||
```
|
||||
|
||||
## Stage 2: HTTP Adapter Wiring
|
||||
|
||||
### Changes
|
||||
|
||||
- Extend `internal/adapters/inbound/httpapi.Service` with the app service method selected in Stage 1.
|
||||
- Add adapter-local clock injection near `alerts_endpoint.go`:
|
||||
|
||||
```go
|
||||
var alertNow = time.Now
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
### Tests
|
||||
|
||||
Update HTTP tests covering:
|
||||
|
||||
- route registration remains unchanged;
|
||||
- handler passes `alertNow().UTC()` to the service;
|
||||
- JSON success response renders filtered service output;
|
||||
- text output renders zero active alerts as `Alerts: 0` when data is non-null;
|
||||
- `data: null` still renders no-data behavior;
|
||||
- `units=us` remains accepted and has no schema effect;
|
||||
- `precision`, `tz`, `TZ`, and unknown query params still return `400`.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/inbound/httpapi
|
||||
```
|
||||
|
||||
## Stage 3: Presenter And Template Check
|
||||
|
||||
### Changes
|
||||
|
||||
- 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.
|
||||
|
||||
### Tests
|
||||
|
||||
Add or update presenter/template-sensitive tests only if current endpoint tests do not prove:
|
||||
|
||||
- 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.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||
```
|
||||
|
||||
## Stage 4: Documentation Updates
|
||||
|
||||
### Changes
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||
```
|
||||
|
||||
Run the full suite:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Manual smoke checks after deployment:
|
||||
|
||||
```sh
|
||||
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=json'
|
||||
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=text'
|
||||
```
|
||||
|
||||
Expected behavior when all alerts in the latest stored run are expired:
|
||||
|
||||
- JSON returns a non-null `data` object with `alerts: []`.
|
||||
- Text output shows the run metadata and `Alerts: 0`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change weatherfeeder ingestion behavior in this cleanup.
|
||||
- Do not alter Postgres tables or SQL filtering unless a later performance issue justifies it.
|
||||
- Do not make `weatherapi` poll NWS or any upstream provider.
|
||||
- Do not rename `/alerts/active`.
|
||||
- Do not add alert history endpoints in this cleanup.
|
||||
- Do not introduce generic filtering frameworks; use small app-local helpers.
|
||||
- Do not move route query validation into the app layer.
|
||||
- Do not move active filtering into presenters or text templates.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `weatherfeeder` persists alert snapshots that were active at ingestion time, but `weatherapi` must still enforce request-time active semantics.
|
||||
- Cancellation messages are operational metadata, not active hazards, and should not be returned by `/alerts/active`.
|
||||
- Missing `expires` should not suppress an alert unless future real-world data shows this creates stale records.
|
||||
- Time comparisons should use UTC instants; timezone presentation is not part of this cleanup.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# SPC Convective Outlook API Roadmap
|
||||
|
||||
There are no active roadmap items for the SPC convective outlook API.
|
||||
|
||||
Current behavior is documented in:
|
||||
|
||||
- [`docs/api.md`](../api.md)
|
||||
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
|
||||
- [`docs/internal/presenters.md`](../internal/presenters.md)
|
||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
|
||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
||||
|
||||
Future changes to convective outlook behavior should be proposed in a new
|
||||
roadmap entry before implementation.
|
||||
@@ -109,7 +109,7 @@ Likely causes:
|
||||
|
||||
- unknown query parameter;
|
||||
- `precision` outside `0` through `2`, or not an integer;
|
||||
- `precision` used on alerts, discussions, or weather stories;
|
||||
- `precision` used on alerts, discussions, weather stories, or outlooks;
|
||||
- `tz` used on observations, current conditions, or alerts;
|
||||
- invalid timezone value;
|
||||
- both `tz` and `TZ` are present with different values.
|
||||
@@ -187,6 +187,10 @@ Safe fix: verify weatherfeeder is running and writing to the same database that
|
||||
`weatherapi` uses as its first configured database. For current conditions,
|
||||
wait for recent observations or inspect weatherfeeder ingestion.
|
||||
|
||||
For convective outlooks, also verify that weatherfeeder has applied its
|
||||
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
|
||||
`outlook_discussions`.
|
||||
|
||||
## Forecast Day Routes Return Empty `periods`
|
||||
|
||||
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.5
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0
|
||||
github.com/lib/pq v1.10.9
|
||||
)
|
||||
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,7 +1,7 @@
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0 h1:4hRiktLMFS8Arl98j21YYskXT8RHHQGFDPJxOF3CpwU=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -1315,9 +1315,8 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
var payload struct {
|
||||
Data struct {
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"outlooks"`
|
||||
Outlooks []map[string]any `json:"outlooks"`
|
||||
Discussions []outlookDiscussionCheck `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
@@ -1326,9 +1325,20 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
if payload.Data.LocationID != "stl" {
|
||||
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
|
||||
}
|
||||
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" {
|
||||
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0]["id"] != "cat-1" {
|
||||
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
|
||||
}
|
||||
for _, field := range []string{"headline", "summary", "discussion"} {
|
||||
if _, ok := payload.Data.Outlooks[0][field]; ok {
|
||||
t.Fatalf("expected outlook polygon to omit %s, got %+v", field, payload.Data.Outlooks[0])
|
||||
}
|
||||
}
|
||||
if len(payload.Data.Discussions) != 1 {
|
||||
t.Fatalf("expected one discussion, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
if payload.Data.Discussions[0].Day != 1 || payload.Data.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("unexpected discussions payload: %+v", payload.Data.Discussions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1369,7 +1379,8 @@ func TestOutlookTextResponseUsesTemplate(t *testing.T) {
|
||||
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") {
|
||||
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") ||
|
||||
!strings.Contains(body, "Discussions: 1") || !strings.Contains(body, "Day 1 discussion") {
|
||||
t.Fatalf("expected outlook text template body, got %q", body)
|
||||
}
|
||||
}
|
||||
@@ -1390,11 +1401,15 @@ func TestOutlookXMLResponseRenders(t *testing.T) {
|
||||
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
|
||||
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "<Discussions>") || !strings.Contains(w.Body.String(), "<Headline>Day 1 headline</Headline>") {
|
||||
t.Fatalf("expected outlook XML discussions, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
run := testOutlookRun()
|
||||
run.Outlooks = []model.WeatherOutlook{}
|
||||
run.Discussions = []model.WeatherOutlookDiscussion{}
|
||||
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1409,6 +1424,7 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
Data struct {
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []model.WeatherOutlook `json:"outlooks"`
|
||||
Discussions []model.WeatherOutlookDiscussion `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
@@ -1420,6 +1436,39 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
|
||||
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
|
||||
}
|
||||
if payload.Data.Discussions == nil || len(payload.Data.Discussions) != 0 {
|
||||
t.Fatalf("expected empty discussions slice, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookTimezoneQuery(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?tz=CDT", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var payload outlookTimePayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode outlook payload: %v", err)
|
||||
}
|
||||
assertOffsetSeconds(t, payload.Data.AsOf, -5*60*60)
|
||||
assertOffsetSeconds(t, *payload.Data.IssuedAt, -5*60*60)
|
||||
if len(payload.Data.Outlooks) != 1 {
|
||||
t.Fatalf("expected one outlook, got %+v", payload.Data.Outlooks)
|
||||
}
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidFrom, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidTo, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ExpiresAt, -5*60*60)
|
||||
if len(payload.Data.Discussions) != 1 || payload.Data.Discussions[0].UpdatedAt == nil {
|
||||
t.Fatalf("expected one discussion with updatedAt, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
assertOffsetSeconds(t, *payload.Data.Discussions[0].UpdatedAt, -5*60*60)
|
||||
}
|
||||
|
||||
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
||||
@@ -2211,7 +2260,7 @@ func testRenderers(t *testing.T) *render.Registry {
|
||||
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{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_latest.txt.tmpl": "Latest Weather Story",
|
||||
"alerts_active.txt.tmpl": "Alerts text",
|
||||
@@ -2250,6 +2299,7 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
|
||||
|
||||
func testOutlookRun() *model.WeatherOutlookRun {
|
||||
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
||||
return &model.WeatherOutlookRun{
|
||||
LocationID: "stl",
|
||||
LocationName: "St. Louis",
|
||||
@@ -2270,6 +2320,13 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
||||
ContainsLocation: true,
|
||||
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
|
||||
}},
|
||||
Discussions: []model.WeatherOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Day 1 headline",
|
||||
Summary: "Day 1 summary",
|
||||
Discussion: "Day 1 discussion",
|
||||
UpdatedAt: &discussionUpdatedAt,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2343,6 +2400,27 @@ type discussionFocusedTimePayload struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type outlookDiscussionCheck struct {
|
||||
Day int `json:"day"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
type outlookTimePayload struct {
|
||||
Data struct {
|
||||
AsOf time.Time `json:"asOf"`
|
||||
IssuedAt *time.Time `json:"issuedAt"`
|
||||
Outlooks []struct {
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo time.Time `json:"validTo"`
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
} `json:"outlooks"`
|
||||
Discussions []struct {
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
} `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -22,10 +22,14 @@ func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location)
|
||||
AsOf: inLocationTime(run.AsOf, tz),
|
||||
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
|
||||
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
|
||||
Discussions: make([]model.WeatherOutlookDiscussion, 0, len(run.Discussions)),
|
||||
}
|
||||
for _, outlook := range run.Outlooks {
|
||||
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
|
||||
}
|
||||
for _, discussion := range run.Discussions {
|
||||
out.Discussions = append(out.Discussions, copyOutlookDiscussion(discussion, tz))
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -44,9 +48,6 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
||||
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
|
||||
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
|
||||
Forecaster: outlook.Forecaster,
|
||||
Headline: outlook.Headline,
|
||||
Summary: outlook.Summary,
|
||||
Discussion: outlook.Discussion,
|
||||
SourceURL: outlook.SourceURL,
|
||||
ImageURL: outlook.ImageURL,
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
@@ -57,6 +58,16 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
||||
return out
|
||||
}
|
||||
|
||||
func copyOutlookDiscussion(discussion model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion {
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: discussion.Day,
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: inLocationTimePtr(discussion.UpdatedAt, tz),
|
||||
}
|
||||
}
|
||||
|
||||
func copyIntPtr(v *int) *int {
|
||||
if v == nil {
|
||||
return nil
|
||||
|
||||
@@ -440,6 +440,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||
issuedAt := asOf.Add(-1 * time.Hour)
|
||||
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
|
||||
severityRank := 5
|
||||
latitude := 38.627123
|
||||
longitude := -90.199456
|
||||
@@ -465,14 +466,18 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: asOf.Add(6 * time.Hour),
|
||||
Forecaster: "DIAL",
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
SourceURL: "https://example.test/source",
|
||||
ImageURL: "https://example.test/image.png",
|
||||
ContainsLocation: true,
|
||||
Geometry: geometry,
|
||||
}},
|
||||
Discussions: []model.WeatherOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
UpdatedAt: &discussionUpdatedAt,
|
||||
}},
|
||||
}
|
||||
|
||||
payload := OutlookRunPayload(run, UnitsUS, loc)
|
||||
@@ -489,9 +494,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if len(out.Outlooks) != 1 {
|
||||
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
|
||||
}
|
||||
if len(out.Discussions) != 1 {
|
||||
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
|
||||
}
|
||||
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
|
||||
t.Fatalf("expected severity rank pointer to be copied")
|
||||
}
|
||||
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
|
||||
t.Fatalf("expected discussion updatedAt pointer to be copied")
|
||||
}
|
||||
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
|
||||
t.Fatalf("expected geometry bytes to be copied")
|
||||
}
|
||||
@@ -502,6 +513,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
|
||||
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
|
||||
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -5*60*60)
|
||||
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
|
||||
t.Fatalf("expected timezone conversion to preserve instants")
|
||||
}
|
||||
@@ -515,12 +527,19 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if string(out.Outlooks[0].Geometry) != string(geometry) {
|
||||
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
|
||||
}
|
||||
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
|
||||
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
|
||||
out.Discussions[0].Discussion != "Discussion text." {
|
||||
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
|
||||
}
|
||||
|
||||
*out.Latitude = 99
|
||||
*out.Longitude = -99
|
||||
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
*out.Outlooks[0].SeverityRank = 99
|
||||
*out.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
out.Outlooks[0].Geometry[0] = '['
|
||||
out.Discussions[0].Headline = "changed"
|
||||
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
|
||||
t.Fatalf("expected source run pointers not to mutate")
|
||||
}
|
||||
@@ -530,8 +549,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if string(run.Outlooks[0].Geometry) != string(geometry) {
|
||||
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
|
||||
}
|
||||
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
|
||||
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if run.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
|
||||
}
|
||||
assertOffsetSeconds(t, run.AsOf, 0)
|
||||
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
|
||||
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
|
||||
}
|
||||
|
||||
func float64Ptr(v float64) *float64 {
|
||||
|
||||
@@ -41,9 +41,6 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
||||
IssuedAt: row.IssuedAt.UTC(),
|
||||
ExpiresAt: row.ExpiresAt.UTC(),
|
||||
Forecaster: stringValue(row.Forecaster),
|
||||
Headline: stringValue(row.Headline),
|
||||
Summary: stringValue(row.Summary),
|
||||
Discussion: stringValue(row.Discussion),
|
||||
SourceURL: stringValue(row.SourceURL),
|
||||
ImageURL: stringValue(row.ImageURL),
|
||||
ContainsLocation: row.ContainsLocation,
|
||||
@@ -51,6 +48,16 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion {
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: row.Day,
|
||||
Headline: stringValue(row.Headline),
|
||||
Summary: stringValue(row.Summary),
|
||||
Discussion: stringValue(row.Discussion),
|
||||
UpdatedAt: timePtr(row.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v sql.NullInt64) *int {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
|
||||
@@ -79,9 +79,6 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Forecaster: sql.NullString{String: "DIAL", Valid: true},
|
||||
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
|
||||
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
|
||||
ContainsLocation: true,
|
||||
@@ -103,8 +100,8 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
|
||||
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
|
||||
}
|
||||
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" {
|
||||
t.Fatalf("unexpected text fields: %+v", outlook)
|
||||
if outlook.Forecaster != "DIAL" {
|
||||
t.Fatalf("unexpected forecaster: %+v", outlook)
|
||||
}
|
||||
if outlook.SourceURL == "" || outlook.ImageURL == "" {
|
||||
t.Fatalf("expected source and image URLs: %+v", outlook)
|
||||
@@ -142,8 +139,7 @@ func TestMapOutlookRowMissingOptionals(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" ||
|
||||
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
|
||||
}
|
||||
if outlook.SeverityRank != nil {
|
||||
@@ -172,3 +168,49 @@ func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
|
||||
t.Fatal("expected invalid geometry error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapOutlookDiscussionRowMapsFields(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
|
||||
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||
DiscussionIndex: 2,
|
||||
Day: 2,
|
||||
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
|
||||
})
|
||||
|
||||
if discussion.Day != 2 {
|
||||
t.Fatalf("expected day 2, got %d", discussion.Day)
|
||||
}
|
||||
if discussion.Headline != "Severe storms possible" {
|
||||
t.Fatalf("unexpected headline: %q", discussion.Headline)
|
||||
}
|
||||
if discussion.Summary != "Scattered severe storms are possible." {
|
||||
t.Fatalf("unexpected summary: %q", discussion.Summary)
|
||||
}
|
||||
if discussion.Discussion != "Discussion text." {
|
||||
t.Fatalf("unexpected discussion: %q", discussion.Discussion)
|
||||
}
|
||||
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
|
||||
t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) {
|
||||
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||
DiscussionIndex: 1,
|
||||
Day: 1,
|
||||
})
|
||||
|
||||
if discussion.Day != 1 {
|
||||
t.Fatalf("expected day 1, got %d", discussion.Day)
|
||||
}
|
||||
if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" {
|
||||
t.Fatalf("expected empty optional strings, got %+v", discussion)
|
||||
}
|
||||
if discussion.UpdatedAt != nil {
|
||||
t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ SELECT
|
||||
issued_at,
|
||||
expires_at,
|
||||
forecaster,
|
||||
headline,
|
||||
summary,
|
||||
discussion,
|
||||
source_url,
|
||||
image_url,
|
||||
contains_location,
|
||||
@@ -42,4 +39,16 @@ SELECT
|
||||
FROM outlooks
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY outlook_index ASC`
|
||||
|
||||
queryOutlookDiscussionsForRun = `
|
||||
SELECT
|
||||
discussion_index,
|
||||
day,
|
||||
headline,
|
||||
summary,
|
||||
discussion,
|
||||
updated_at
|
||||
FROM outlook_discussions
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY discussion_index ASC`
|
||||
)
|
||||
|
||||
@@ -40,6 +40,12 @@ func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.Wea
|
||||
}
|
||||
run.Outlooks = outlooks
|
||||
|
||||
discussions, err := r.loadOutlookDiscussions(ctx, row.EventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run.Discussions = discussions
|
||||
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
@@ -68,9 +74,6 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
||||
&row.IssuedAt,
|
||||
&row.ExpiresAt,
|
||||
&row.Forecaster,
|
||||
&row.Headline,
|
||||
&row.Summary,
|
||||
&row.Discussion,
|
||||
&row.SourceURL,
|
||||
&row.ImageURL,
|
||||
&row.ContainsLocation,
|
||||
@@ -90,3 +93,32 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) {
|
||||
rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query outlook discussions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]model.WeatherOutlookDiscussion, 0)
|
||||
for rows.Next() {
|
||||
var row outlookDiscussionRow
|
||||
if err := rows.Scan(
|
||||
&row.DiscussionIndex,
|
||||
&row.Day,
|
||||
&row.Headline,
|
||||
&row.Summary,
|
||||
&row.Discussion,
|
||||
&row.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan outlook discussion row: %w", err)
|
||||
}
|
||||
|
||||
out = append(out, mapOutlookDiscussionRow(row))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate outlook discussion rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal file
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
// outlooks_read_test.go validates outlook repository read flow.
|
||||
// Layer: adapters/outbound/postgres outlook read tests.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const outlookTestDriverName = "weatherapi_outlook_read_test"
|
||||
|
||||
func init() {
|
||||
sql.Register(outlookTestDriverName, outlookTestDriver{})
|
||||
}
|
||||
|
||||
func TestLatestConvectiveOutlookRunLoadsOutlooksAndDiscussions(t *testing.T) {
|
||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||
issuedAt := asOf.Add(-1 * time.Hour)
|
||||
discussionUpdated := asOf.Add(-30 * time.Minute)
|
||||
repo, closeDB := openOutlookTestRepository(t,
|
||||
outlookParentQuery([][]driver.Value{{
|
||||
"evt-outlook-run",
|
||||
"stl",
|
||||
"St. Louis",
|
||||
float64(38.62),
|
||||
float64(-90.2),
|
||||
asOf,
|
||||
issuedAt,
|
||||
}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
outlookReadRow(1, "day-1", 1, "categorical"),
|
||||
outlookReadRow(2, "day-2", 2, "wind"),
|
||||
}, nil),
|
||||
outlookDiscussionsQuery([][]driver.Value{
|
||||
{int64(1), int64(1), "Day 1 headline", "Day 1 summary", "Day 1 discussion", discussionUpdated},
|
||||
{int64(2), int64(2), "Day 2 headline", nil, "Day 2 discussion", nil},
|
||||
}, nil),
|
||||
)
|
||||
defer closeDB()
|
||||
|
||||
run, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if run == nil {
|
||||
t.Fatal("expected outlook run")
|
||||
}
|
||||
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
|
||||
t.Fatalf("unexpected run metadata: %+v", run)
|
||||
}
|
||||
if len(run.Outlooks) != 2 {
|
||||
t.Fatalf("expected 2 outlooks, got %d", len(run.Outlooks))
|
||||
}
|
||||
if run.Outlooks[0].ID != "day-1" || run.Outlooks[1].ID != "day-2" {
|
||||
t.Fatalf("expected outlook order from rows, got %+v", run.Outlooks)
|
||||
}
|
||||
if len(run.Discussions) != 2 {
|
||||
t.Fatalf("expected 2 discussions, got %d", len(run.Discussions))
|
||||
}
|
||||
if run.Discussions[0].Day != 1 || run.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("unexpected first discussion: %+v", run.Discussions[0])
|
||||
}
|
||||
if run.Discussions[1].Day != 2 || run.Discussions[1].Summary != "" {
|
||||
t.Fatalf("unexpected second discussion: %+v", run.Discussions[1])
|
||||
}
|
||||
if run.Discussions[0].UpdatedAt == nil || run.Discussions[0].UpdatedAt.Location().String() != "UTC" {
|
||||
t.Fatalf("expected discussion updatedAt UTC pointer, got %v", run.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if run.Discussions[1].UpdatedAt != nil {
|
||||
t.Fatalf("expected nil discussion updatedAt, got %v", run.Discussions[1].UpdatedAt)
|
||||
}
|
||||
assertOutlookTestQueriesConsumed(t)
|
||||
}
|
||||
|
||||
func TestLatestConvectiveOutlookRunMissingParentReturnsNil(t *testing.T) {
|
||||
repo, closeDB := openOutlookTestRepository(t, outlookParentQuery(nil))
|
||||
defer closeDB()
|
||||
|
||||
run, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if run != nil {
|
||||
t.Fatalf("expected nil run, got %+v", run)
|
||||
}
|
||||
assertOutlookTestQueriesConsumed(t)
|
||||
}
|
||||
|
||||
func TestLatestConvectiveOutlookRunWrapsReadErrors(t *testing.T) {
|
||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queries []scriptedOutlookQuery
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "parent query",
|
||||
queries: []scriptedOutlookQuery{
|
||||
{name: "parent", query: queryLatestConvectiveOutlookRun, err: errors.New("parent unavailable")},
|
||||
},
|
||||
want: "query latest convective outlook run",
|
||||
},
|
||||
{
|
||||
name: "outlooks query",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
{name: "outlooks", query: queryOutlooksForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("outlooks unavailable")},
|
||||
},
|
||||
want: "query outlooks",
|
||||
},
|
||||
{
|
||||
name: "outlook scan",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
{int64(1), "day-1"},
|
||||
}, nil),
|
||||
},
|
||||
want: "scan outlook row",
|
||||
},
|
||||
{
|
||||
name: "outlook map",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
outlookReadRowWithGeometry(1, "day-1", 1, "categorical", `{"type":"Point"`),
|
||||
}, nil),
|
||||
},
|
||||
want: "map outlook row",
|
||||
},
|
||||
{
|
||||
name: "outlook iteration",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
outlookReadRow(1, "day-1", 1, "categorical"),
|
||||
}, errors.New("outlook iteration failed")),
|
||||
},
|
||||
want: "iterate outlook rows",
|
||||
},
|
||||
{
|
||||
name: "discussions query",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery(nil, nil),
|
||||
{name: "discussions", query: queryOutlookDiscussionsForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("discussions unavailable")},
|
||||
},
|
||||
want: "query outlook discussions",
|
||||
},
|
||||
{
|
||||
name: "discussion scan",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery(nil, nil),
|
||||
outlookDiscussionsQuery([][]driver.Value{
|
||||
{int64(1), "bad day", nil, nil, nil, nil},
|
||||
}, nil),
|
||||
},
|
||||
want: "scan outlook discussion row",
|
||||
},
|
||||
{
|
||||
name: "discussion iteration",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery(nil, nil),
|
||||
outlookDiscussionsQuery([][]driver.Value{
|
||||
{int64(1), int64(1), nil, nil, nil, nil},
|
||||
}, errors.New("discussion iteration failed")),
|
||||
},
|
||||
want: "iterate outlook discussion rows",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo, closeDB := openOutlookTestRepository(t, tt.queries...)
|
||||
defer closeDB()
|
||||
|
||||
_, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func openOutlookTestRepository(t *testing.T, queries ...scriptedOutlookQuery) (*Repository, func()) {
|
||||
t.Helper()
|
||||
outlookTestScript.set(queries)
|
||||
|
||||
db, err := sql.Open(outlookTestDriverName, "")
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
return NewRepository(db), func() {
|
||||
_ = db.Close()
|
||||
outlookTestScript.set(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOutlookTestQueriesConsumed(t *testing.T) {
|
||||
t.Helper()
|
||||
if remaining := outlookTestScript.remaining(); remaining != 0 {
|
||||
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func outlookParentQuery(rows [][]driver.Value) scriptedOutlookQuery {
|
||||
return scriptedOutlookQuery{
|
||||
name: "parent",
|
||||
query: queryLatestConvectiveOutlookRun,
|
||||
columns: []string{"event_id", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at"},
|
||||
rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func outlookRowsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
|
||||
return scriptedOutlookQuery{
|
||||
name: "outlooks",
|
||||
query: queryOutlooksForRun,
|
||||
args: []driver.Value{"evt-outlook-run"},
|
||||
columns: []string{"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"},
|
||||
rows: rows,
|
||||
nextErr: nextErr,
|
||||
}
|
||||
}
|
||||
|
||||
func outlookDiscussionsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
|
||||
return scriptedOutlookQuery{
|
||||
name: "discussions",
|
||||
query: queryOutlookDiscussionsForRun,
|
||||
args: []driver.Value{"evt-outlook-run"},
|
||||
columns: []string{"discussion_index", "day", "headline", "summary", "discussion", "updated_at"},
|
||||
rows: rows,
|
||||
nextErr: nextErr,
|
||||
}
|
||||
}
|
||||
|
||||
func outlookReadRow(index int64, id string, day int64, outlookType string) []driver.Value {
|
||||
return outlookReadRowWithGeometry(index, id, day, outlookType, `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`)
|
||||
}
|
||||
|
||||
func outlookReadRowWithGeometry(index int64, id string, day int64, outlookType string, geometry string) []driver.Value {
|
||||
validFrom := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
validTo := validFrom.Add(6 * time.Hour)
|
||||
return []driver.Value{
|
||||
index,
|
||||
id,
|
||||
"spc",
|
||||
"convective",
|
||||
day,
|
||||
outlookType,
|
||||
"SLGT",
|
||||
"Slight Risk",
|
||||
int64(5),
|
||||
validFrom,
|
||||
validTo,
|
||||
validFrom.Add(-1 * time.Hour),
|
||||
validTo,
|
||||
"DIAL",
|
||||
"https://example.test/source",
|
||||
"https://example.test/image.png",
|
||||
true,
|
||||
geometry,
|
||||
}
|
||||
}
|
||||
|
||||
type outlookTestDriver struct{}
|
||||
|
||||
func (outlookTestDriver) Open(string) (driver.Conn, error) {
|
||||
return outlookTestConn{}, nil
|
||||
}
|
||||
|
||||
type outlookTestConn struct{}
|
||||
|
||||
func (outlookTestConn) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("prepare is not supported")
|
||||
}
|
||||
|
||||
func (outlookTestConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (outlookTestConn) Begin() (driver.Tx, error) {
|
||||
return nil, errors.New("transactions are not supported")
|
||||
}
|
||||
|
||||
func (outlookTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
return outlookTestScript.next(query, args)
|
||||
}
|
||||
|
||||
type scriptedOutlookQuery struct {
|
||||
name string
|
||||
query string
|
||||
args []driver.Value
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
err error
|
||||
nextErr error
|
||||
}
|
||||
|
||||
type outlookTestScriptState struct {
|
||||
mu sync.Mutex
|
||||
queries []scriptedOutlookQuery
|
||||
}
|
||||
|
||||
var outlookTestScript outlookTestScriptState
|
||||
|
||||
func (s *outlookTestScriptState) set(queries []scriptedOutlookQuery) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.queries = append([]scriptedOutlookQuery(nil), queries...)
|
||||
}
|
||||
|
||||
func (s *outlookTestScriptState) remaining() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.queries)
|
||||
}
|
||||
|
||||
func (s *outlookTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(s.queries) == 0 {
|
||||
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
|
||||
}
|
||||
next := s.queries[0]
|
||||
s.queries = s.queries[1:]
|
||||
|
||||
if compactSQL(query) != compactSQL(next.query) {
|
||||
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
|
||||
}
|
||||
if len(args) != len(next.args) {
|
||||
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
|
||||
}
|
||||
for i, arg := range args {
|
||||
if arg.Value != next.args[i] {
|
||||
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
|
||||
}
|
||||
}
|
||||
if next.err != nil {
|
||||
return nil, next.err
|
||||
}
|
||||
return &outlookTestRows{
|
||||
columns: append([]string(nil), next.columns...),
|
||||
rows: append([][]driver.Value(nil), next.rows...),
|
||||
nextErr: next.nextErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type outlookTestRows struct {
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
index int
|
||||
nextErr error
|
||||
}
|
||||
|
||||
func (r *outlookTestRows) Columns() []string {
|
||||
return r.columns
|
||||
}
|
||||
|
||||
func (r *outlookTestRows) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *outlookTestRows) Next(dest []driver.Value) error {
|
||||
if r.index >= len(r.rows) {
|
||||
if r.nextErr != nil {
|
||||
err := r.nextErr
|
||||
r.nextErr = nil
|
||||
return err
|
||||
}
|
||||
return io.EOF
|
||||
}
|
||||
copy(dest, r.rows[r.index])
|
||||
r.index++
|
||||
return nil
|
||||
}
|
||||
|
||||
func compactSQL(query string) string {
|
||||
return strings.Join(strings.Fields(query), " ")
|
||||
}
|
||||
|
||||
func namedValues(args []driver.NamedValue) []driver.Value {
|
||||
out := make([]driver.Value, len(args))
|
||||
for i := range args {
|
||||
out[i] = args[i].Value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -32,11 +32,17 @@ type outlookRow struct {
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Forecaster sql.NullString
|
||||
Headline sql.NullString
|
||||
Summary sql.NullString
|
||||
Discussion sql.NullString
|
||||
SourceURL sql.NullString
|
||||
ImageURL sql.NullString
|
||||
ContainsLocation bool
|
||||
GeometryJSON string
|
||||
}
|
||||
|
||||
type outlookDiscussionRow struct {
|
||||
DiscussionIndex int
|
||||
Day int
|
||||
Headline sql.NullString
|
||||
Summary sql.NullString
|
||||
Discussion sql.NullString
|
||||
UpdatedAt sql.NullTime
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFil
|
||||
}
|
||||
}
|
||||
out.Outlooks = outlooks
|
||||
out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -123,6 +124,12 @@ func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||
out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
|
||||
}
|
||||
}
|
||||
if run.Discussions != nil {
|
||||
out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions))
|
||||
for i := range run.Discussions {
|
||||
out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i])
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -135,6 +142,31 @@ func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion {
|
||||
out := discussion
|
||||
out.UpdatedAt = copyTime(discussion.UpdatedAt)
|
||||
return out
|
||||
}
|
||||
|
||||
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion {
|
||||
if len(outlooks) == 0 {
|
||||
return []model.WeatherOutlookDiscussion{}
|
||||
}
|
||||
|
||||
retainedDays := make(map[int]struct{}, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
retainedDays[outlook.Day] = struct{}{}
|
||||
}
|
||||
|
||||
out := discussions[:0]
|
||||
for _, discussion := range discussions {
|
||||
if _, ok := retainedDays[discussion.Day]; ok {
|
||||
out = append(out, discussion)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyFloat64(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -131,6 +132,7 @@ func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||
if run == nil || run.LocationID != "stl" {
|
||||
t.Fatalf("unexpected outlook run: %+v", run)
|
||||
}
|
||||
assertDiscussionDays(t, run, []int{1, 2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
|
||||
@@ -208,6 +210,7 @@ func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"day-2"})
|
||||
assertDiscussionDays(t, run, []int{2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||
@@ -219,6 +222,7 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"tor-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||
@@ -230,7 +234,21 @@ func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1", "day-2"})
|
||||
assertDiscussionDays(t, run, []int{1, 2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookContainsLocationFalseReturnsEmptyRun(t *testing.T) {
|
||||
containsLocation := false
|
||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ContainsLocation: &containsLocation})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, nil)
|
||||
assertDiscussionDays(t, run, nil)
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
||||
@@ -243,6 +261,7 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||
@@ -262,6 +281,7 @@ func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
||||
@@ -277,12 +297,14 @@ func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
||||
t.Fatalf("unexpected validFrom error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, fromRun, []string{"cat-1"})
|
||||
assertDiscussionDays(t, fromRun, []int{1})
|
||||
|
||||
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected validTo error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, toRun, nil)
|
||||
assertDiscussionDays(t, toRun, nil)
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
|
||||
@@ -306,6 +328,12 @@ func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *test
|
||||
if len(run.Outlooks) != 0 {
|
||||
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
|
||||
}
|
||||
if run.Discussions == nil {
|
||||
t.Fatal("expected empty discussions slice, got nil")
|
||||
}
|
||||
if len(run.Discussions) != 0 {
|
||||
t.Fatalf("expected no discussions, got %+v", run.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
|
||||
@@ -325,9 +353,12 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
||||
*run.Longitude = -99
|
||||
*run.IssuedAt = testTime(99)
|
||||
*run.Outlooks[0].SeverityRank = 99
|
||||
*run.Discussions[0].UpdatedAt = testTime(98)
|
||||
run.Outlooks[0].Geometry[0] = '{'
|
||||
run.Outlooks[0].ID = "changed"
|
||||
run.Discussions[0].Headline = "changed"
|
||||
run.Outlooks = run.Outlooks[:1]
|
||||
run.Discussions = run.Discussions[:1]
|
||||
|
||||
if *original.Latitude != 38.62 {
|
||||
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
||||
@@ -350,6 +381,15 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
||||
if len(original.Outlooks) != 3 {
|
||||
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
|
||||
}
|
||||
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
|
||||
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if original.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
|
||||
}
|
||||
if len(original.Discussions) != 3 {
|
||||
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePropagatesErrors(t *testing.T) {
|
||||
@@ -376,8 +416,25 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
||||
Outlooks: []model.WeatherOutlook{
|
||||
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
|
||||
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
|
||||
testOutlook("day-2", 2, "wind", false, testTime(18), testTime(24), 2, `["wind"]`),
|
||||
testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
|
||||
},
|
||||
Discussions: []model.WeatherOutlookDiscussion{
|
||||
testOutlookDiscussion(1),
|
||||
testOutlookDiscussion(2),
|
||||
testOutlookDiscussion(3),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
|
||||
updatedAt := testTime(9 + day)
|
||||
dayText := strconv.Itoa(day)
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: day,
|
||||
Headline: "Day " + dayText + " headline",
|
||||
Summary: "Day " + dayText + " summary",
|
||||
Discussion: "Day " + dayText + " discussion",
|
||||
UpdatedAt: &updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,9 +453,6 @@ func testOutlook(id string, day int, outlookType string, containsLocation bool,
|
||||
IssuedAt: validFrom.Add(-time.Hour),
|
||||
ExpiresAt: validTo,
|
||||
Forecaster: "DIAL",
|
||||
Headline: id + " headline",
|
||||
Summary: id + " summary",
|
||||
Discussion: id + " discussion",
|
||||
SourceURL: "https://example.test/" + id,
|
||||
ImageURL: "https://example.test/" + id + ".png",
|
||||
ContainsLocation: containsLocation,
|
||||
@@ -424,3 +478,18 @@ func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int) {
|
||||
t.Helper()
|
||||
if run == nil {
|
||||
t.Fatal("expected outlook run")
|
||||
}
|
||||
if len(run.Discussions) != len(want) {
|
||||
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
||||
}
|
||||
for i := range want {
|
||||
if run.Discussions[i].Day != want[i] {
|
||||
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,28 @@ Contains Location: {{$outlook.ContainsLocation}}
|
||||
{{- if $outlook.LabelText}}
|
||||
Label Text: {{$outlook.LabelText}}
|
||||
{{- end}}
|
||||
{{- if $outlook.Headline}}
|
||||
Headline: {{$outlook.Headline}}
|
||||
{{- end}}
|
||||
{{- if $outlook.Summary}}
|
||||
Summary: {{$outlook.Summary}}
|
||||
{{- end}}
|
||||
{{- if $outlook.SourceURL}}
|
||||
Source URL: {{$outlook.SourceURL}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
Discussions: {{len .Data.Discussions}}
|
||||
{{- range $i, $discussion := .Data.Discussions}}
|
||||
|
||||
[{{$i}}] Day {{$discussion.Day}} Discussion
|
||||
{{- if $discussion.UpdatedAt}}
|
||||
Updated At: {{$discussion.UpdatedAt}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Headline}}
|
||||
Headline: {{$discussion.Headline}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Summary}}
|
||||
Summary: {{$discussion.Summary}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Discussion}}
|
||||
Discussion: {{$discussion.Discussion}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No convective outlook data available.
|
||||
{{- end}}
|
||||
|
||||
Reference in New Issue
Block a user