Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4dd701204 |
@@ -26,14 +26,11 @@ database credentials through your normal secret-management process.
|
||||
- Hourly and narrative forecasts
|
||||
- Forecast discussions
|
||||
- Weather stories
|
||||
- Convective outlooks:
|
||||
- `GET /outlooks/convective`
|
||||
- `GET /outlooks/convective/active`
|
||||
- `GET /outlooks/convective/location`
|
||||
- Convective outlooks
|
||||
|
||||
Common query parameters include `format`, `units`, and route-specific options
|
||||
such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters
|
||||
`day`, `outlookType`, and `containsLocation`.
|
||||
`day` and `outlookType`.
|
||||
|
||||
See [`docs/api.md`](docs/api.md) for the HTTP contract.
|
||||
|
||||
|
||||
63
docs/api.md
63
docs/api.md
@@ -234,7 +234,6 @@ GET /alerts/active?format=text
|
||||
```http
|
||||
GET /outlooks/convective
|
||||
GET /outlooks/convective/active
|
||||
GET /outlooks/convective/location
|
||||
```
|
||||
|
||||
Returns the latest SPC convective outlook run reconstructed from
|
||||
@@ -246,9 +245,6 @@ Route behavior:
|
||||
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`. 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` and `discussions` are
|
||||
@@ -262,14 +258,10 @@ Query parameters:
|
||||
| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above |
|
||||
| `day` | all outlook routes | `1`, `2`, or `3` |
|
||||
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
|
||||
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
|
||||
|
||||
`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.
|
||||
outlooks are already filtered for the configured location. `precision`,
|
||||
`containsLocation`, and unknown parameters are rejected.
|
||||
|
||||
Run `data` fields:
|
||||
|
||||
@@ -314,7 +306,7 @@ Examples:
|
||||
|
||||
```http
|
||||
GET /outlooks/convective?day=1&outlookType=categorical
|
||||
GET /outlooks/convective/location?format=text&tz=CDT
|
||||
GET /outlooks/convective/active?format=text&tz=CDT
|
||||
```
|
||||
|
||||
Example JSON response:
|
||||
@@ -367,53 +359,8 @@ Example JSON response:
|
||||
}
|
||||
```
|
||||
|
||||
Example location-filtered JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"locationId": "stl",
|
||||
"asOf": "2026-06-11T18:00:00Z",
|
||||
"outlooks": [
|
||||
{
|
||||
"id": "spc-day1-tor-2pct",
|
||||
"provider": "spc",
|
||||
"product": "convective",
|
||||
"day": 1,
|
||||
"outlookType": "tornado",
|
||||
"label": "2%",
|
||||
"validFrom": "2026-06-11T18:00:00Z",
|
||||
"validTo": "2026-06-12T12:00:00Z",
|
||||
"issuedAt": "2026-06-11T17:00:00Z",
|
||||
"expiresAt": "2026-06-12T12:00:00Z",
|
||||
"containsLocation": true,
|
||||
"geometry": {
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Text format uses the shared convective outlook template for all three outlook
|
||||
routes and renders a no-data message when `data` is null.
|
||||
Text format uses the shared convective outlook template for both outlook routes
|
||||
and renders a no-data message when `data` is null.
|
||||
|
||||
### Forecasts
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ This document describes the internal HTTP adapter under
|
||||
|
||||
The HTTP adapter turns feedapi route definitions into calls on the application
|
||||
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 filter construction,
|
||||
alert active-time selection, response envelopes, and template names.
|
||||
|
||||
## Inputs and Outputs
|
||||
@@ -36,7 +36,7 @@ The adapter may:
|
||||
- choose the presenter function for an endpoint;
|
||||
- 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 filters.
|
||||
|
||||
The adapter must not:
|
||||
|
||||
@@ -99,10 +99,8 @@ common query values are lowercased and trimmed before binding where applicable.
|
||||
available only through binders used by forecast, discussion, weather story, and
|
||||
outlook routes.
|
||||
|
||||
Outlook routes accept `day`, `outlookType`, and, except for
|
||||
`/outlooks/convective/location`, `containsLocation`. The location route always
|
||||
adds `containsLocation=true` after binding and rejects an explicit
|
||||
`containsLocation` query value.
|
||||
Outlook routes accept `day` and `outlookType`. `containsLocation` is a response
|
||||
field only and is rejected as a request parameter.
|
||||
|
||||
## Timezone Parsing
|
||||
|
||||
@@ -144,17 +142,13 @@ Outlook route filters are built at the HTTP boundary and passed to the
|
||||
application service:
|
||||
|
||||
- `/outlooks/convective` uses only user-supplied filters;
|
||||
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`;
|
||||
- `/outlooks/convective/location` adds the same active timestamp and
|
||||
`ContainsLocation=true`.
|
||||
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`.
|
||||
|
||||
The package variable `outlookNow` exists so endpoint tests can make active and
|
||||
location filtering deterministic.
|
||||
The package variable `outlookNow` exists so endpoint tests can make active
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
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 routes derive filtered copies from the latest stored run.
|
||||
|
||||
## Configuration and CLI Boundaries
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# Active Alert Filtering Cleanup
|
||||
|
||||
## Status
|
||||
|
||||
Implementation, current-behavior documentation, and automated verification are
|
||||
complete.
|
||||
|
||||
## Verification Record
|
||||
|
||||
Completed on 2026-06-12:
|
||||
|
||||
- `go test ./internal/app`
|
||||
- `go test ./internal/adapters/inbound/httpapi`
|
||||
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
||||
- `go test ./...`
|
||||
|
||||
Deployment smoke checks were not run from this workspace because the local
|
||||
repository state is not a deployed `weatherapi` instance.
|
||||
|
||||
## Summary
|
||||
|
||||
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -15,8 +15,8 @@ GET {{baseUrl}}/alerts/active?format=text
|
||||
GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical
|
||||
Accept: application/json
|
||||
|
||||
### Active convective outlooks for the configured location as text
|
||||
GET {{baseUrl}}/outlooks/convective/location?format=text&tz=CDT
|
||||
### Active convective outlooks as text
|
||||
GET {{baseUrl}}/outlooks/convective/active?format=text&tz=CDT
|
||||
|
||||
### Hourly forecast in US units
|
||||
GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago
|
||||
|
||||
@@ -1484,13 +1484,15 @@ func TestOutlookRoutesRegistered(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/outlooks/convective",
|
||||
"/outlooks/convective/active",
|
||||
"/outlooks/convective/location",
|
||||
} {
|
||||
def := definitionForPath(t, defs, path)
|
||||
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
||||
t.Fatalf("%s: expected GET definition, got %+v", path, def.Methods)
|
||||
}
|
||||
}
|
||||
if endpointExists(defs, "/outlooks/convective/location") {
|
||||
t.Fatal("expected /outlooks/convective/location to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
@@ -1499,7 +1501,6 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/outlooks/convective",
|
||||
"/outlooks/convective/active",
|
||||
"/outlooks/convective/location",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
|
||||
@@ -1676,7 +1677,7 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
||||
h := newHandler(t, svc, "/outlooks/convective")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&containsLocation=true&tz=CDT&units=US", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&tz=CDT&units=US", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -1692,15 +1693,12 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
||||
if filter.OutlookType != "tornado" {
|
||||
t.Fatalf("expected outlookType tornado, got %q", filter.OutlookType)
|
||||
}
|
||||
if filter.ContainsLocation == nil || !*filter.ContainsLocation {
|
||||
t.Fatalf("expected containsLocation true, got %+v", filter.ContainsLocation)
|
||||
}
|
||||
if filter.ActiveAt != nil {
|
||||
t.Fatalf("expected no active filter, got %v", filter.ActiveAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
|
||||
func TestOutlookActiveFilterUsesNow(t *testing.T) {
|
||||
now := time.Date(2026, 6, 11, 15, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
setOutlookNowForTest(t, now)
|
||||
|
||||
@@ -1716,28 +1714,6 @@ func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
|
||||
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
|
||||
t.Fatalf("expected activeAt %s, got %v", now.UTC(), activeFilter.ActiveAt)
|
||||
}
|
||||
if activeFilter.ContainsLocation != nil {
|
||||
t.Fatalf("expected active route not to force containsLocation, got %+v", activeFilter.ContainsLocation)
|
||||
}
|
||||
|
||||
locationSvc := &fakeService{outlookRun: testOutlookRun()}
|
||||
locationHandler := newHandler(t, locationSvc, "/outlooks/convective/location")
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/outlooks/convective/location?outlookType=hail", nil)
|
||||
locationHandler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected location 200, got %d", w.Code)
|
||||
}
|
||||
locationFilter := locationSvc.outlookFilters[0]
|
||||
if locationFilter.ActiveAt == nil || !locationFilter.ActiveAt.Equal(now.UTC()) {
|
||||
t.Fatalf("expected location activeAt %s, got %v", now.UTC(), locationFilter.ActiveAt)
|
||||
}
|
||||
if locationFilter.ContainsLocation == nil || !*locationFilter.ContainsLocation {
|
||||
t.Fatalf("expected location route to force containsLocation true, got %+v", locationFilter.ContainsLocation)
|
||||
}
|
||||
if locationFilter.OutlookType != "hail" {
|
||||
t.Fatalf("expected outlookType hail, got %q", locationFilter.OutlookType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
|
||||
@@ -1748,10 +1724,10 @@ func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
|
||||
"/outlooks/convective?day=4",
|
||||
"/outlooks/convective?day=two",
|
||||
"/outlooks/convective?outlookType=snow",
|
||||
"/outlooks/convective?containsLocation=maybe",
|
||||
"/outlooks/convective?containsLocation=true",
|
||||
"/outlooks/convective/active?containsLocation=true",
|
||||
"/outlooks/convective?tz=not-a-timezone",
|
||||
"/outlooks/convective?tz=CDT&TZ=EST",
|
||||
"/outlooks/convective/location?containsLocation=true",
|
||||
} {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
|
||||
@@ -2440,6 +2416,15 @@ func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) en
|
||||
return endpoint.Definition{}
|
||||
}
|
||||
|
||||
func endpointExists(defs []endpoint.Definition, path string) bool {
|
||||
for _, def := range defs {
|
||||
if def.Path == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func testRenderers(t *testing.T) *render.Registry {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ type outlookFilterMode int
|
||||
const (
|
||||
outlookFilterUser outlookFilterMode = iota
|
||||
outlookFilterActive
|
||||
outlookFilterLocation
|
||||
)
|
||||
|
||||
var outlookNow = time.Now
|
||||
@@ -27,7 +26,6 @@ func outlookDefinitions(svc Service) []endpoint.Definition {
|
||||
return []endpoint.Definition{
|
||||
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
|
||||
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
|
||||
outlookDefinition("/outlooks/convective/location", outlookFilterLocation, bindOutlookLocationQuery, svc),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +40,10 @@ func outlookDefinition(
|
||||
binder,
|
||||
func(ctx context.Context, req outlookQueryRequest) (any, error) {
|
||||
filter := req.Filter
|
||||
if mode == outlookFilterActive || mode == outlookFilterLocation {
|
||||
if mode == outlookFilterActive {
|
||||
activeAt := outlookNow().UTC()
|
||||
filter.ActiveAt = &activeAt
|
||||
}
|
||||
if mode == outlookFilterLocation {
|
||||
containsLocation := true
|
||||
filter.ContainsLocation = &containsLocation
|
||||
}
|
||||
|
||||
run, err := svc.LatestConvectiveOutlook(ctx, filter)
|
||||
if err != nil {
|
||||
|
||||
@@ -145,29 +145,16 @@ func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQ
|
||||
}
|
||||
|
||||
func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) {
|
||||
return bindOutlookQueryInternal(r, true)
|
||||
}
|
||||
|
||||
func bindOutlookLocationQuery(r *http.Request) (outlookQueryRequest, error) {
|
||||
return bindOutlookQueryInternal(r, false)
|
||||
}
|
||||
|
||||
func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outlookQueryRequest, error) {
|
||||
normalizeCommonQueryValue(r, "units")
|
||||
normalizeCommonQueryValue(r, "format")
|
||||
normalizeCommonQueryValue(r, "outlookType")
|
||||
|
||||
allowedExtra := []string{"tz", "TZ", "day", "outlookType"}
|
||||
if allowContainsLocation {
|
||||
allowedExtra = append(allowedExtra, "containsLocation")
|
||||
}
|
||||
|
||||
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
||||
AllowUnits: true,
|
||||
AllowFormat: true,
|
||||
DefaultUnits: string(presenter.UnitsMetric),
|
||||
RejectUnknown: true,
|
||||
}, allowedExtra...)
|
||||
}, "tz", "TZ", "day", "outlookType")
|
||||
if err != nil {
|
||||
return outlookQueryRequest{}, err
|
||||
}
|
||||
@@ -182,7 +169,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
|
||||
return outlookQueryRequest{}, err
|
||||
}
|
||||
|
||||
filter, err := bindOutlookFilter(r, allowContainsLocation)
|
||||
filter, err := bindOutlookFilter(r)
|
||||
if err != nil {
|
||||
return outlookQueryRequest{}, err
|
||||
}
|
||||
@@ -194,7 +181,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.OutlookFilter, error) {
|
||||
func bindOutlookFilter(r *http.Request) (app.OutlookFilter, error) {
|
||||
var filter app.OutlookFilter
|
||||
|
||||
if strings.TrimSpace(r.URL.Query().Get("day")) != "" {
|
||||
@@ -218,16 +205,5 @@ func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.Outlook
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(r.URL.Query().Get("containsLocation")) != "" {
|
||||
if !allowContainsLocation {
|
||||
return app.OutlookFilter{}, apierrors.InvalidParameter("containsLocation is not allowed on this endpoint")
|
||||
}
|
||||
containsLocation, err := bind.OptionalBool(r, "containsLocation", false)
|
||||
if err != nil {
|
||||
return app.OutlookFilter{}, err
|
||||
}
|
||||
filter.ContainsLocation = &containsLocation
|
||||
}
|
||||
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
@@ -25,10 +25,9 @@ type Repository interface {
|
||||
|
||||
// OutlookFilter selects outlook entries from the latest convective outlook run.
|
||||
type OutlookFilter struct {
|
||||
Day *int
|
||||
OutlookType string
|
||||
ContainsLocation *bool
|
||||
ActiveAt *time.Time
|
||||
Day *int
|
||||
OutlookType string
|
||||
ActiveAt *time.Time
|
||||
}
|
||||
|
||||
// Service provides weather read use-cases.
|
||||
@@ -120,9 +119,6 @@ func matchesOutlookFilter(outlook model.WeatherOutlook, filter OutlookFilter) bo
|
||||
if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) {
|
||||
return false
|
||||
}
|
||||
if filter.ContainsLocation != nil && outlook.ContainsLocation != *filter.ContainsLocation {
|
||||
return false
|
||||
}
|
||||
if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -358,32 +358,6 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||
containsLocation := true
|
||||
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, []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) {
|
||||
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||
@@ -399,16 +373,14 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
||||
|
||||
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||
day := 1
|
||||
containsLocation := true
|
||||
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
|
||||
Day: &day,
|
||||
OutlookType: "categorical",
|
||||
ContainsLocation: &containsLocation,
|
||||
ActiveAt: &activeAt,
|
||||
Day: &day,
|
||||
OutlookType: "categorical",
|
||||
ActiveAt: &activeAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user