diff --git a/__tests__/client/components/AvalancheProblemCard.client.test.tsx b/__tests__/client/components/AvalancheProblemCard.client.test.tsx new file mode 100644 index 000000000..b5ec503c3 --- /dev/null +++ b/__tests__/client/components/AvalancheProblemCard.client.test.tsx @@ -0,0 +1,87 @@ +import { AvalancheProblemCard } from '@/components/forecast/AvalancheProblemCard' +import { + AvalancheProblemLikelihood, + AvalancheProblemLocation, + AvalancheProblemName, + AvalancheProblemType, + MediaType, + type AvalancheProblem, +} from '@/services/nac/types/forecastSchemas' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' + +const baseProblem: AvalancheProblem = { + id: 1, + forecast_id: 100, + rank: 1, + avalanche_problem_id: AvalancheProblemType.StormSlab, + name: AvalancheProblemName.StormSlab, + likelihood: AvalancheProblemLikelihood.Likely, + location: [ + AvalancheProblemLocation.NorthUpper, + AvalancheProblemLocation.NorthMiddle, + AvalancheProblemLocation.NortheastUpper, + ], + size: [1, 2], + discussion: '
Watch for storm slabs on north-facing terrain.
', + problem_description: 'Storm Slab description', + icon: 'http://api.avalanche.org/img/avalanche_problems/StormSlab.png', + media: { type: MediaType.Unknown }, +} + +describe('AvalancheProblemCard', () => { + it('renders the problem name', () => { + render(Avalanche danger will rise through the day as a storm moves in and temperatures warm up. Shift to simpler, lower-angle terrain during stormy periods or if you see signs of instability like shooting cracks, rollerballs, or fresh avalanches.<\/p>", + "hazard_discussion": "
West North has been the outlier forecast zone over the last few days, with more snow and avalanche activity over the weekend and into Monday than anyplace else in the Cascades. Precipitation amounts haven't been especially noteworthy, nor have avalanches been very large, but the 4-10 inches of new snow just haven't bonded well to the crust layer just below it. This weak interface produced numerous loose snow avalanches and some slabs as well. The snowpack beneath recent snow is a mix of crusts with rounded grains and soft melt forms—a product of repeated swings between cold, snowy weather and warmer, rainy periods this winter.<\/p>\n
The most important factor heading into the next storm is the current snow surface. Light-density surface snow has faceted, with weak snow lingering even on sunnier aspects where you’d typically expect to only find melt-freeze crusts. A few days of faceting alone isn’t a classic setup for a persistent weak layer, but it is enough to increase sensitivity. Expect fresh slabs to be more reactive than usual, even with relatively moderate amounts of new snow.<\/p>", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [ + { + "id": 1, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "(3\/30\/2026)
Small soft slabs and sluffs in very steep areas with a human trigger. They ran on the crust beneath the 6 inches or so of snow that fell in the previous days. <\/p> Photo: Jess Trimble ", + "favorite": true + }, + { + "id": 2, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-large.heic", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-medium.heic", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c.heic", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-thumbnail.heic" + }, + "type": "image", + "title": "Public Observation: Swift Creek\/Annette", + "caption": "
(3\/30\/2026)<\/p>\n
A human-triggered slab on the northeast side of Huntoon Point at 5100 feet.<\/p>\n
Photo: Conner Niemann<\/p>", + "favorite": false + }, + { + "id": 3, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/29\/2026) Photo: Lee Lazzara", + "favorite": false + }, + { + "id": 4, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-large.JPG", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-medium.JPG", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853.JPG", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-thumbnail.JPG" + }, + "type": "image", + "title": null, + "caption": "(3\/26\/2026)
A natural D3 slab avalanche on the North Face of Shuksan. With the crown slightly filled in, this likely released on March 25th during a period of heavy snowfall and strong winds. Northerly aspect at roughly 7500 feet.<\/p>\n
(HS-N-D3-U)<\/p> Photo: Lee Lazzara", + "favorite": false + } + ], + "weather_data": null, + "json_data": null, + "avalanche_center": { + "id": "NWAC", + "name": "Northwest Avalanche Center", + "url": "https:\/\/www.nwac.us\/", + "city": "Seattle", + "state": "WA" + }, + "forecast_avalanche_problems": [ + { + "id": 2986878, + "forecast_id": 184213, + "avalanche_problem_id": 2, + "rank": 1, + "likelihood": "likely", + "discussion": "
The incoming storm isn’t arriving cleanly—expect variable snowfall rates, fluctuating snow levels, and shifting winds. Avalanche danger could rise quickly in the afternoon or evening, especially during heavier snowfall, but shallow, reactive slabs may exist from the start. Conditions will vary widely across the zone, with more precipitation near the Baker Backcountry and less along the Canadian border and in North Cascades National Park.<\/p>\n
You are more likely to trigger a slab on steep slopes that are loading up with wind-drifted snow. But with weak, faceted snow or crusts at the current snow surface, be ready to see shallow slabs in all terrain, wind-loaded or not. Track conditions through the day. As snowfall, wind, and poor visibility increase, step back from bigger, steeper, and more complex terrain.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "
(12\/16\/2024)<\/p>\n
You could find the slab in wind-sheltered terrain on Wednesday. Weak snow surfaces mean you might see some reactivity even with modest amounts of new snow.<\/p>\n
Photo: Gregg Oliveri<\/p>" + }, + "location": [ + "north upper", + "northeast upper", + "east upper", + "southeast upper", + "south upper", + "southwest upper", + "west upper", + "northwest upper", + "north middle", + "northeast middle", + "east middle", + "southeast middle", + "south middle", + "southwest middle", + "west middle", + "northwest middle" + ], + "size": ["1", "2"], + "name": "Storm Slab", + "problem_description": "Storm Slab avalanches are the release of a cohesive layer (a slab) of new snow that breaks within new snow or on the old snow surface. Storm-slabs typically last between a few hours and few days (following snowfall). Storm-slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/StormSlab.png" + }, + { + "id": 2986879, + "forecast_id": 184213, + "avalanche_problem_id": 6, + "rank": 2, + "likelihood": "possible", + "discussion": "
Wet loose avalanches should be on your radar at low and middle elevations as rising snow levels spit rain onto a bit of new snow. While all should be small, they can still pose a real hazard in rugged, marginally snow-covered terrain or where open creeks exist in the runout. Avoid travel on or directly beneath steep slopes where you see fresh rollerballs, pinwheels, or small wet avalanches.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "
(1\/20\/2024)<\/p>\n
Rain on new snow is a near-guarantee of snow sliding down the hill.<\/p>\n
Photo: Julz Holder - Grounding Truth<\/p>" + }, + "location": [ + "north middle", + "northeast middle", + "east middle", + "southeast middle", + "south middle", + "southwest middle", + "west middle", + "northwest middle", + "north lower", + "northeast lower", + "east lower", + "southeast lower", + "south lower", + "southwest lower", + "west lower", + "northwest lower" + ], + "size": ["1", "1.5"], + "name": "Wet Loose", + "problem_description": "Wet Loose avalanches are the release of wet unconsolidated snow or slush. These avalanches typically occur within layers of wet snow near the surface of the snowpack, but they may quickly gouge into lower snowpack layers. Like Loose Dry Avalanches, they start at a point and entrain snow as they move downhill, forming a fan-shaped avalanche. Other names for loose-wet avalanches include point-release avalanches or sluffs. Loose Wet avalanches can trigger slab avalanches that break into deeper snow layers.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/WetLoose.png" + } + ], + "danger": [ + { "lower": 2, "upper": 3, "middle": 2, "valid_day": "current" }, + { "lower": 1, "upper": 2, "middle": 2, "valid_day": "tomorrow" } + ], + "forecast_zone": [ + { + "id": 1646, + "name": "West Slopes North", + "url": "http:\/\/www.nwac.us\/avalanche-forecast\/#\/west-slopes-north", + "state": "WA", + "zone_id": "4", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/nwac-forecast.json b/__tests__/server/fixtures/nwac-forecast.json new file mode 100644 index 000000000..f034860b6 --- /dev/null +++ b/__tests__/server/fixtures/nwac-forecast.json @@ -0,0 +1,36 @@ +{ + "id": 184066, + "published_time": "2026-03-30T01:30:00+00:00", + "expires_time": "2026-04-20T01:30:00+00:00", + "created_at": "2026-03-29T23:12:44+00:00", + "updated_at": "2026-03-29T23:35:19+00:00", + "author": "Sam Clairmont", + "product_type": "summary", + "bottom_line": null, + "hazard_discussion": "
Avalanche forecasts have ended for the 2025-26 season in the Olympic Mountains. Daily afternoon Mountain Weather Forecasts<\/a> will continue through early May. <\/strong><\/p>\n Longer days and the potential for more sunshine make the mountains an inviting place in the spring. However, avalanche accidents and fatalities can occur on snow-covered mountains in April<\/a>. Make sure you have the experience and skills to evaluate avalanche terrain, snowpack conditions, and weather observations to decide when, where, and how to travel in the mountains.<\/p>\n During winter-like storms: <\/strong><\/p>\n During periods of cold clear nights and warm sunny days:<\/strong><\/p>\n Other springtime hazards: <\/strong><\/p>\n Even though avalanche forecasting has ended for the season, NWAC will continue to track snow and weather conditions. Large spring storms and\/or widespread avalanche cycles may cause us to issue special avalanche bulletins, avalanche warnings, or other spring statements to keep you informed.<\/p>\n You can help your backcountry community by continuing to submit observations on our public site<\/a>. <\/p>\n Have a fun and safe spring playing in the mountains!<\/p>\n Contact NWAC Backcountry travelers could easily trigger large avalanches today, especially in locations with 6 or more inches of new snow. Consider avoiding avalanche terrain in areas where clues to unstable snow are present, such as slopes filling in with wind drifted snow, snow surface cracking, or recent avalanches.<\/p>",
+ "hazard_discussion": "",
+ "weather_discussion": null,
+ "announcement": null,
+ "status": "published",
+ "media": [
+ {
+ "id": 1,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/31\/2026) About an inch of new snow on the summit of Andesite Peak. Many rocks and other obstacles remain exposed. Now they just have a dusting of new snow. <\/p>\n Andesite Peak, Donner Summit are, CA<\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 2,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/28\/2026) More exposed rocks and other obstacles continue to melt out each day.<\/p>\n Summit of Andesite Peak.<\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 3,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/29\/2026) N-NE-E aspects like the Hourglass on Tamarack Peak still have decent snow coverage while S aspects like those on Mt. Rose are mostly dirt. <\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 4,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": "Looking west",
+ "caption": "(3\/28\/2026) Looking west towards Round Top. <\/p> Photo: Mark Speicher",
+ "favorite": false
+ },
+ {
+ "id": 5,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": "Red Lake Peak",
+ "caption": "(3\/28\/2026) Red Lake Peak in the distance. South faces have little to no snow left. <\/p> Photo: Mark Speicher",
+ "favorite": false
+ },
+ {
+ "id": 6,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": "Forestdale Rd. ",
+ "caption": "(3\/28\/2026) Forestdale Rd is 60% covered with snow to the bridge. Trucks have not yet driven down the road. It is still managable for a snowmobile. <\/p> Photo: Mark Speicher",
+ "favorite": false
+ },
+ {
+ "id": 7,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/28\/2026) Snowmelt continues to accelerate on E aspects as well leaving patchy snow coverage and lots of exposed debris.<\/p>\n Andesite Peak<\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 8,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/27\/2026) More rocks and other obstacles are starting to melt out on northerly aspects, but they still have decent coverage, especially above 9000 ft. Much less snow and much more bare ground exists on the sunnier south through west aspects. <\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 9,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/27\/2026) What was on the snow travel on March 25 is now dirt in some places on the approach to Mt. Houghton.<\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 10,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/25\/2026) Walking the Rubicon summer trail from 6900' to 7600' on most all dirt trail.<\/p>\n Rubicon Peak, West Shore.<\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 11,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/26\/2026) NE\/E face of Elephants Back.<\/p>\n Carson Pass area.<\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ },
+ {
+ "id": 12,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": "(3\/27\/2026) Mount Houghton's SE gullies have lost significant snow since March 19th. <\/p> Photo: Sierra Avalanche Center",
+ "favorite": false
+ }
+ ],
+ "weather_data": { "weather_product_id": 184245 },
+ "json_data": null,
+ "avalanche_center": {
+ "id": "SAC",
+ "name": "Sierra Avalanche Center",
+ "url": "https:\/\/www.sierraavalanchecenter.org\/",
+ "city": "Truckee",
+ "state": "CA"
+ },
+ "forecast_avalanche_problems": [
+ {
+ "id": 2988958,
+ "forecast_id": 184250,
+ "avalanche_problem_id": 2,
+ "rank": 1,
+ "likelihood": "likely",
+ "discussion": " Avalanche hazard is expected to vary significantly today from one location to another around the forecast area due to a very wide range in new snow accumulation both over the past 24 hours and during the day today. Consider avoiding avalanche terrain today, especially in locations with 6 or more inches of new snow, where slopes are filling in with wind drifted snow, where you encounter snow surface cracking, or see signs of recent avalanches. A firm crust at the old\/new snow interface may act as an efficient bed surface and allow for some long running avalanches today.<\/p>\n As new snow continues to accumulate today, expect to encounter areas of unstable snow in both wind exposed and wind protected terrain. Wind speeds are forecast to reach gale force\/extreme speeds this afternoon. This increase in wind speeds is expected to increase the amount of wind loading that occurs in near treeline and below treeline areas as the day progresses. There is also the chance of some dry or wet loose snow avalanches today, where new snow slides on firm crust at the old\/new snow interface.<\/p>\n Before this storm, significant snowmelt occurred across the forecast area. Large areas of bare ground were present on all aspects below 7,500' to 8,000' as well as on southerly aspects at all elevations. Today's avalanche concerns are focused on locations where new snow has fallen on top of old snow. Where new snow has fallen on bare ground, avalanche concerns are minimal. In all areas, numerous hazards such as rocks, stumps, logs, etc are barely hidden under the recent new snow. Anticipate conditions similar to early season, where it is very easy to hit hard objects under the snow surface as you travel.<\/p>",
+ "media": {
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": " Snow surface cracking like this indicates that you have found unstable snow. Where this is the case, consider opting for slopes with a slope angle under 30 degrees until you have navigated away from avalanche terrain.<\/p>\n This photo was taken earlier this year near Blue Lakes, CA.<\/p>\n Photo: Jason Bilek<\/p>"
+ },
+ "location": [
+ "northwest middle",
+ "southeast middle",
+ "southeast upper",
+ "northwest upper",
+ "north upper",
+ "north middle",
+ "northeast middle",
+ "northeast upper",
+ "east upper",
+ "east middle",
+ "south upper",
+ "southwest upper",
+ "west upper",
+ "south middle",
+ "southwest middle",
+ "west middle",
+ "north lower",
+ "northeast lower",
+ "east lower",
+ "southeast lower",
+ "south lower",
+ "southwest lower",
+ "west lower",
+ "northwest lower"
+ ],
+ "size": ["1", "2"],
+ "name": "Storm Slab",
+ "problem_description": "Storm Slab avalanches are the release of a cohesive layer (a slab) of new snow that breaks within new snow or on the old snow surface. Storm-slabs typically last between a few hours and few days (following snowfall). Storm-slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.",
+ "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/StormSlab.png"
+ }
+ ],
+ "danger": [
+ { "lower": 3, "upper": 3, "middle": 3, "valid_day": "current" },
+ { "lower": 3, "upper": 3, "middle": 3, "valid_day": "tomorrow" }
+ ],
+ "forecast_zone": [
+ {
+ "id": 2458,
+ "name": "Central Sierra Nevada",
+ "url": "https:\/\/www.sierraavalanchecenter.org\/forecasts\/avalanche\/central-sierra-nevada#\/central-sierra-nevada\/",
+ "state": "CA",
+ "zone_id": "1",
+ "config": null
+ }
+ ]
+}
diff --git a/__tests__/server/fixtures/sac-warning.json b/__tests__/server/fixtures/sac-warning.json
new file mode 100644
index 000000000..fc2f62a5b
--- /dev/null
+++ b/__tests__/server/fixtures/sac-warning.json
@@ -0,0 +1,7 @@
+{
+ "avalanche_center": null,
+ "published_time": null,
+ "expires_time": null,
+ "created_at": null,
+ "updated_at": null
+}
diff --git a/__tests__/server/fixtures/snfac-forecast.json b/__tests__/server/fixtures/snfac-forecast.json
new file mode 100644
index 000000000..0b1e4a08c
--- /dev/null
+++ b/__tests__/server/fixtures/snfac-forecast.json
@@ -0,0 +1,142 @@
+{
+ "id": 184224,
+ "published_time": "2026-04-01T12:56:00+00:00",
+ "expires_time": "2026-04-02T10:00:00+00:00",
+ "created_at": "2026-04-01T10:54:07+00:00",
+ "updated_at": "2026-04-01T13:36:05+00:00",
+ "author": "Zachary Miller",
+ "product_type": "forecast",
+ "bottom_line": " Watch for and avoid fresh wind drifts and slabs. You will find more, and larger, drifts and slabs as you climb from middle to upper elevations. At lower elevations, steer clear of steep slopes if you are sinking into a wet, mushy snowpack.<\/p>\n We will issue our last daily Avalanche Forecast of the season this Sunday, April 5th. We will issue General Avalanche Information updates through late April or early May. <\/em><\/p>",
+ "hazard_discussion": " Just when we thought winter had punched its timecard and clocked out - April Fools - it's back! With the return of snowfall, it's worth turning our brains back into winter mode and out of the simplified spring-diurnal mindset. Keep your head on a swivel for changing conditions if you're out enjoying the cold new snow, especially in areas where you find more than 6\" of fresh snow. Spring storms are notoriously spotty in character, with large amounts of variability in the timing and quantity of snowfall across the landscape. Additionally, convective storms could produce graupel or other snow crystal types that form sensitive mid-storm interfaces. These interfaces make great failure layers for even thin wind slabs that quickly form above them.<\/p>\n Conveniently, the recent warm weather has solidified much of the snowpack below this new snowfall. On steep slopes where new snow is accumulating on firm, hard ice or wind-hammered surfaces, loose snow avalanches (sluffs)<\/a> could release naturally or be easy to trigger and run faster and further than expected. At lower elevations near or below the rain-snow line, wet avalanches are still in the mix, and avoiding steep slopes where you find unsupportable wet snow is a great way to play it safe. Given the variety of conditions you may experience in the mountains today, be ready to pull the plug and switch to a more conservative plan B if you see obvious signs of instability or feel the hairs on the back of your neck stand up.<\/p>",
+ "weather_discussion": null,
+ "announcement": null,
+ "status": "published",
+ "media": [
+ {
+ "id": 1,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": "Public Observation: Bench Hut",
+ "caption": "(3\/31\/2026) Wet loose off the Defibrillator couloir (north facing) observed on 3\/31\/2026 in the Sawtooth Range.<\/p> Photo: Jon Preuss",
+ "favorite": false
+ },
+ {
+ "id": 2,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": "Pairie Creek wet slab avalanche",
+ "caption": " (3\/26\/2026) Wet slab and wet loose avalanches. E-SE @ 9,300-9,800'. Prairie Ck. <\/p>\n Photo: Sawtooth Avalanche Center<\/p>",
+ "favorite": false
+ },
+ {
+ "id": 3,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": "Public Observation: Marshall Lakes Basin",
+ "caption": " (3\/23\/2026) Wet Loose avalanche observed in Marshall Lakes Basin in the Northern Sawtooth Range.<\/p>\n Photo: Bernard, Rich, Seymore, Armstomg, Anderson<\/p>",
+ "favorite": false
+ },
+ {
+ "id": 4,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": "Public Observation: Bench Lakes",
+ "caption": " (3\/22\/2026) Large recent wet loose avalanches on Williams Peak's S face.<\/p>",
+ "favorite": false
+ },
+ {
+ "id": 5,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-large.jpg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-medium.jpg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9.jpg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-thumbnail.jpg"
+ },
+ "type": "image",
+ "title": null,
+ "caption": " (3\/21\/2026) This photo was taken a couple of minutes after pouring purple Gatorade onto the snow surface. The layers where water collects, or pools, stand out in the photo, especially the 2 crusts immediately above the faceted snow at\/near the ground. <\/p>\n Photo: Sawtooth Avalanche Center<\/p>",
+ "favorite": false
+ },
+ {
+ "id": 6,
+ "url": {
+ "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-large.jpeg",
+ "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-medium.jpeg",
+ "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6.jpeg",
+ "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-thumbnail.jpeg"
+ },
+ "type": "image",
+ "title": "Peak 9367",
+ "caption": " (3\/18\/2026) Wet loose avalanche ~8500' on a west aspect in the Smiley Creek drainage on Peak 9367.<\/p>\n Photo: Sawtooth Avalanche Center<\/p>",
+ "favorite": false
+ }
+ ],
+ "weather_data": { "weather_product_id": 184222 },
+ "json_data": null,
+ "avalanche_center": {
+ "id": "SNFAC",
+ "name": "Sawtooth Avalanche Center",
+ "url": "https:\/\/www.sawtoothavalanche.com\/",
+ "city": "Ketchum",
+ "state": "ID"
+ },
+ "forecast_avalanche_problems": [
+ {
+ "id": 2988903,
+ "forecast_id": 184224,
+ "avalanche_problem_id": 3,
+ "rank": 1,
+ "likelihood": "possible",
+ "discussion": " You could find wind slabs up to 2 feet thick in exposed terrain. Wind slabs are largest and most widespread at upper elevations.<\/p>\n Recent cornice growth, pillows, and dunes<\/a> are all clues that wind was at work moving snow. Look for shooting cracks<\/a> as evidence of fresh wind slabs and a nudge to move to softer snow on more sheltered slopes. These slabs commonly form beneath ridgelines, on the sides of cross-loaded gullies, and lower in cirques below large cliff bands, chutes, and couloirs.<\/p>",
+ "media": { "url": "", "type": "", "caption": "" },
+ "location": [
+ "north upper",
+ "northeast upper",
+ "east upper",
+ "southeast upper",
+ "south upper",
+ "southwest upper",
+ "west upper",
+ "northwest upper"
+ ],
+ "size": ["1", "2"],
+ "name": "Wind Slab",
+ "problem_description": "Wind Slab avalanches are the release of a cohesive layer of snow (a slab) formed by the wind. Wind typically transports snow from the upwind sides of terrain features and deposits snow on the downwind side. Wind slabs are often smooth and rounded and sometimes sound hollow, and can range from soft to hard. Wind slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.",
+ "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/WindSlab.png"
+ }
+ ],
+ "danger": [
+ { "lower": 2, "upper": 2, "middle": 2, "valid_day": "current" },
+ { "lower": 2, "upper": 3, "middle": 2, "valid_day": "tomorrow" }
+ ],
+ "forecast_zone": [
+ {
+ "id": 2906,
+ "name": "Sawtooth & Western Smoky Mtns",
+ "url": "https:\/\/www.sawtoothavalanche.com\/forecasts\/avalanche\/sawtooth-&-western-smoky-mtns",
+ "state": "ID",
+ "zone_id": "2",
+ "config": null
+ }
+ ]
+}
diff --git a/__tests__/server/fixtures/snfac-warning.json b/__tests__/server/fixtures/snfac-warning.json
new file mode 100644
index 000000000..fc2f62a5b
--- /dev/null
+++ b/__tests__/server/fixtures/snfac-warning.json
@@ -0,0 +1,7 @@
+{
+ "avalanche_center": null,
+ "published_time": null,
+ "expires_time": null,
+ "created_at": null,
+ "updated_at": null
+}
diff --git a/__tests__/server/forecastSchemas.server.test.ts b/__tests__/server/forecastSchemas.server.test.ts
new file mode 100644
index 000000000..56872b725
--- /dev/null
+++ b/__tests__/server/forecastSchemas.server.test.ts
@@ -0,0 +1,166 @@
+import {
+ DangerLevel,
+ ForecastPeriod,
+ forecastResultSchema,
+ mediaItemSchema,
+ MediaType,
+ ProductType,
+ warningResultSchema,
+} from '@/services/nac/types/forecastSchemas'
+import nwacForecastActive from './fixtures/nwac-forecast-active.json'
+import nwacForecastSummary from './fixtures/nwac-forecast.json'
+import nwacWarning from './fixtures/nwac-warning.json'
+import sacForecast from './fixtures/sac-forecast.json'
+import sacWarning from './fixtures/sac-warning.json'
+import snfacForecast from './fixtures/snfac-forecast.json'
+import snfacWarning from './fixtures/snfac-warning.json'
+
+describe('forecastResultSchema', () => {
+ it('parses an active NWAC forecast with string-typed sizes', () => {
+ const result = forecastResultSchema.parse(nwacForecastActive)
+ expect(result.product_type).toBe(ProductType.Forecast)
+ if (result.product_type === ProductType.Forecast) {
+ expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0)
+ // NWAC returns string sizes — verify they are transformed to numbers
+ for (const problem of result.forecast_avalanche_problems) {
+ for (const size of problem.size) {
+ expect(typeof size).toBe('number')
+ }
+ }
+ expect(result.danger.length).toBeGreaterThan(0)
+ expect(result.danger[0].valid_day).toBe(ForecastPeriod.Current)
+ }
+ })
+
+ it('parses an SAC forecast with string-typed sizes', () => {
+ const result = forecastResultSchema.parse(sacForecast)
+ expect(result.product_type).toBe(ProductType.Forecast)
+ if (result.product_type === ProductType.Forecast) {
+ expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0)
+ for (const problem of result.forecast_avalanche_problems) {
+ for (const size of problem.size) {
+ expect(typeof size).toBe('number')
+ }
+ }
+ }
+ })
+
+ it('parses an SNFAC forecast', () => {
+ const result = forecastResultSchema.parse(snfacForecast)
+ expect(result.product_type).toBe(ProductType.Forecast)
+ if (result.product_type === ProductType.Forecast) {
+ expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0)
+ expect(result.avalanche_center.id).toBe('SNFAC')
+ }
+ })
+
+ it('parses an off-season NWAC summary product', () => {
+ const result = forecastResultSchema.parse(nwacForecastSummary)
+ expect(result.product_type).toBe(ProductType.Summary)
+ // Summary products don't have danger or problems in their type
+ expect(result.avalanche_center.id).toBe('NWAC')
+ })
+
+ it('preserves null danger levels as DangerLevel.None', () => {
+ const result = forecastResultSchema.parse(nwacForecastActive)
+ if (result.product_type === ProductType.Forecast) {
+ for (const danger of result.danger) {
+ expect(danger.lower).toBeGreaterThanOrEqual(DangerLevel.None)
+ expect(danger.middle).toBeGreaterThanOrEqual(DangerLevel.None)
+ expect(danger.upper).toBeGreaterThanOrEqual(DangerLevel.None)
+ }
+ }
+ })
+
+ it('parses forecast media items', () => {
+ const result = forecastResultSchema.parse(sacForecast)
+ if (result.product_type === ProductType.Forecast && result.media) {
+ expect(result.media.length).toBeGreaterThan(0)
+ for (const item of result.media) {
+ expect(item.type).not.toBe(MediaType.Unknown)
+ }
+ }
+ })
+})
+
+describe('warningResultSchema', () => {
+ it('parses a null NWAC warning (no active warning)', () => {
+ const result = warningResultSchema.parse(nwacWarning)
+ expect(result.avalanche_center).toBeNull()
+ expect(result.published_time).toBeNull()
+ })
+
+ it('parses a null SAC warning', () => {
+ const result = warningResultSchema.parse(sacWarning)
+ expect(result.avalanche_center).toBeNull()
+ })
+
+ it('parses a null SNFAC warning', () => {
+ const result = warningResultSchema.parse(snfacWarning)
+ expect(result.avalanche_center).toBeNull()
+ })
+})
+
+describe('mediaItemSchema', () => {
+ it('parses an image media item', () => {
+ const item = {
+ id: 123,
+ type: 'image',
+ url: {
+ large: 'https://example.com/large.jpg',
+ medium: 'https://example.com/medium.jpg',
+ original: 'https://example.com/original.jpg',
+ thumbnail: 'https://example.com/thumb.jpg',
+ },
+ caption: 'Test caption',
+ }
+ const result = mediaItemSchema.parse(item)
+ expect(result.type).toBe(MediaType.Image)
+ })
+
+ it('parses a video media item with external link', () => {
+ const item = {
+ id: 456,
+ type: 'video',
+ url: {
+ external_link: 'https://youtube.com/watch?v=abc',
+ external_type: 'video',
+ },
+ caption: 'Video caption',
+ }
+ const result = mediaItemSchema.parse(item)
+ expect(result.type).toBe(MediaType.Video)
+ })
+
+ it('parses a PDF media item', () => {
+ const item = {
+ type: 'pdf',
+ url: {
+ original: 'https://example.com/doc.pdf',
+ },
+ }
+ const result = mediaItemSchema.parse(item)
+ expect(result.type).toBe(MediaType.PDF)
+ })
+
+ it('falls back to unknown for unrecognized media types', () => {
+ const item = {
+ type: 'hologram',
+ url: 'https://example.com/holo',
+ }
+ const result = mediaItemSchema.parse(item)
+ expect(result.type).toBe(MediaType.Unknown)
+ })
+
+ it('parses an empty media item', () => {
+ const item = { type: '', url: '', caption: '' }
+ const result = mediaItemSchema.parse(item)
+ expect(result.type).toBe(MediaType.None)
+ })
+
+ it('parses a null media item', () => {
+ const item = { type: null, url: null, caption: '', title: '' }
+ const result = mediaItemSchema.parse(item)
+ expect(result.type).toBeNull()
+ })
+})
diff --git a/__tests__/server/resolveZone.server.test.ts b/__tests__/server/resolveZone.server.test.ts
new file mode 100644
index 000000000..39b5360af
--- /dev/null
+++ b/__tests__/server/resolveZone.server.test.ts
@@ -0,0 +1,96 @@
+import type { ActiveForecastZoneWithSlug } from '../../src/services/nac/nac'
+import { AvalancheForecastZoneStatus } from '../../src/services/nac/types/schemas'
+
+const mockGetActiveForecastZones = jest.fn()
+
+jest.mock('../../src/services/nac/nac', () => ({
+ getActiveForecastZones: (...args: unknown[]) => mockGetActiveForecastZones(...args),
+}))
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { resolveZoneFromSlug } = require('../../src/services/nac/resolveZone')
+
+const mockZoneConfig = {
+ elevation_band_names: {
+ lower: 'Below Treeline',
+ middle: 'Near Treeline',
+ upper: 'Above Treeline',
+ },
+}
+
+const mockZones: ActiveForecastZoneWithSlug[] = [
+ {
+ slug: 'west-slopes-north',
+ zone: {
+ status: AvalancheForecastZoneStatus.Active,
+ id: 1001,
+ name: 'West Slopes North',
+ url: '/forecasts/west-slopes-north',
+ rank: 1,
+ zone_id: 'WSN',
+ config: mockZoneConfig,
+ },
+ },
+ {
+ slug: 'east-slopes-north',
+ zone: {
+ status: AvalancheForecastZoneStatus.Active,
+ id: 1002,
+ name: 'East Slopes North',
+ url: '/forecasts/east-slopes-north',
+ rank: 2,
+ zone_id: 'ESN',
+ config: mockZoneConfig,
+ },
+ },
+]
+
+beforeEach(() => {
+ mockGetActiveForecastZones.mockReset()
+ mockGetActiveForecastZones.mockResolvedValue(mockZones)
+})
+
+describe('resolveZoneFromSlug', () => {
+ it('returns the correct zone for a valid slug', async () => {
+ const result = await resolveZoneFromSlug('nwac', 'west-slopes-north')
+
+ expect(result).not.toBeNull()
+ expect(result?.slug).toBe('west-slopes-north')
+ expect(result?.zone.id).toBe(1001)
+ expect(result?.zone.name).toBe('West Slopes North')
+ })
+
+ it('returns null for an unknown slug', async () => {
+ const result = await resolveZoneFromSlug('nwac', 'nonexistent-zone')
+
+ expect(result).toBeNull()
+ })
+
+ it('applies DVAC alias — passes nwac to getActiveForecastZones', async () => {
+ await resolveZoneFromSlug('dvac', 'west-slopes-north')
+
+ expect(mockGetActiveForecastZones).toHaveBeenCalledWith('nwac')
+ })
+
+ it('DVAC alias resolves the correct zone', async () => {
+ const result = await resolveZoneFromSlug('dvac', 'east-slopes-north')
+
+ expect(result).not.toBeNull()
+ expect(result?.slug).toBe('east-slopes-north')
+ expect(result?.zone.id).toBe(1002)
+ })
+
+ it('passes through non-dvac center slugs unchanged', async () => {
+ await resolveZoneFromSlug('sac', 'some-zone')
+
+ expect(mockGetActiveForecastZones).toHaveBeenCalledWith('sac')
+ })
+
+ it('returns null when no zones exist for the center', async () => {
+ mockGetActiveForecastZones.mockResolvedValue([])
+
+ const result = await resolveZoneFromSlug('nwac', 'west-slopes-north')
+
+ expect(result).toBeNull()
+ })
+})
diff --git a/__tests__/server/sanitizeHtml.server.test.ts b/__tests__/server/sanitizeHtml.server.test.ts
new file mode 100644
index 000000000..cf7d7bac2
--- /dev/null
+++ b/__tests__/server/sanitizeHtml.server.test.ts
@@ -0,0 +1,27 @@
+import { sanitizeHtml } from '@/components/forecast/sanitizeHtml'
+
+describe('sanitizeHtml', () => {
+ it('opens external (other-domain) links in a new tab with a safe rel', () => {
+ const out = sanitizeHtml(' See NWAC Safe text\n
\n
\n
<\/strong>You can reach us at the forecast desk daily from 8 AM to 4 PM:
Email: forecasters@nwac.us<\/a>
Phone: 206-526-6164 (Office voicemail)
Mail: Northwest Avalanche Center
7600 Sandpoint Way NE
Seattle, Washington 98115<\/p>",
+ "weather_discussion": null,
+ "announcement": null,
+ "status": "published",
+ "media": [],
+ "weather_data": null,
+ "json_data": null,
+ "avalanche_center": {
+ "id": "NWAC",
+ "name": "Northwest Avalanche Center",
+ "url": "https:\/\/www.nwac.us\/",
+ "city": "Seattle",
+ "state": "WA"
+ },
+ "forecast_avalanche_problems": [],
+ "danger": [],
+ "forecast_zone": [
+ {
+ "id": 1645,
+ "name": "Olympics",
+ "url": "http:\/\/www.nwac.us\/avalanche-forecast\/#\/olympics",
+ "state": "WA",
+ "zone_id": "1",
+ "config": null
+ }
+ ]
+}
diff --git a/__tests__/server/fixtures/nwac-warning.json b/__tests__/server/fixtures/nwac-warning.json
new file mode 100644
index 000000000..fc2f62a5b
--- /dev/null
+++ b/__tests__/server/fixtures/nwac-warning.json
@@ -0,0 +1,7 @@
+{
+ "avalanche_center": null,
+ "published_time": null,
+ "expires_time": null,
+ "created_at": null,
+ "updated_at": null
+}
diff --git a/__tests__/server/fixtures/sac-forecast.json b/__tests__/server/fixtures/sac-forecast.json
new file mode 100644
index 000000000..c90808ce7
--- /dev/null
+++ b/__tests__/server/fixtures/sac-forecast.json
@@ -0,0 +1,246 @@
+{
+ "id": 184250,
+ "published_time": "2026-04-01T13:44:00+00:00",
+ "expires_time": "2026-04-02T11:00:00+00:00",
+ "created_at": "2026-04-01T12:52:14+00:00",
+ "updated_at": "2026-04-01T13:44:13+00:00",
+ "author": "Brandon Schwartz",
+ "product_type": "forecast",
+ "bottom_line": "