Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions __tests__/client/components/OnboardingChecklist.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ jest.mock('../../../src/collections/Tenants/components/onboardingActions', () =>
}))

const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
builtInPages: { count: 0, expected: 7 },
forecastPages: { count: 0, expected: 2 },
defaultBuiltInPages: { count: 0, expected: 5 },
pages: { created: 0, expected: 5, missing: [] },
homePage: false,
navigation: false,
Expand All @@ -49,7 +50,8 @@ const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningS
})

const fullyProvisioned = buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 5, expected: 5, missing: [] },
homePage: true,
navigation: true,
Expand Down Expand Up @@ -83,7 +85,7 @@ describe('OnboardingChecklist', () => {
render(<OnboardingChecklist />)
await flushAsync()

expect(screen.queryByText('(0/7)')).not.toBeInTheDocument()
expect(screen.queryByText('(0/2)')).not.toBeInTheDocument()
expect(screen.queryByText('(0/5)')).not.toBeInTheDocument()
expect(screen.queryByText('colors.css')).not.toBeInTheDocument()
expect(screen.queryByText('centerColorMap')).not.toBeInTheDocument()
Expand All @@ -93,7 +95,8 @@ describe('OnboardingChecklist', () => {
// Automated items complete but pages incomplete — needsProvisioning returns false,
// so auto-provision doesn't run but the button shows
const incompleteStatus = buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] },
homePage: true,
navigation: true,
Expand All @@ -119,10 +122,37 @@ describe('OnboardingChecklist', () => {
})

describe('loaded', () => {
it('shows forecast and default built-in page labels', async () => {
render(<OnboardingChecklist />)
await flushAsync()

expect(screen.getByText('Forecast Built-In pages')).toBeInTheDocument()
expect(screen.getByText('Default Built-In pages')).toBeInTheDocument()
})

it('shows forecast and default built-in page counts', async () => {
render(<OnboardingChecklist />)
await flushAsync()

expect(screen.getByText('(2/2)')).toBeInTheDocument()
// (5/5) appears for both defaultBuiltInPages and pages
expect(screen.getAllByText('(5/5)')).toHaveLength(2)
})

it('shows NAC zones description when fully provisioned', async () => {
render(<OnboardingChecklist />)
await flushAsync()

expect(
screen.getByText('Forecast built-ins are automatically based on NAC zones.'),
).toBeInTheDocument()
})

it('shows missing pages', async () => {
mockCheckStatus.mockResolvedValue({
status: buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] },
homePage: true,
navigation: true,
Expand Down
24 changes: 16 additions & 8 deletions __tests__/client/components/needsProvisioning.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { needsProvisioning } from '@/collections/Tenants/components/needsProvisi
import type { ProvisioningStatus } from '@/collections/Tenants/components/onboardingActions'

const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
builtInPages: { count: 0, expected: 7 },
forecastPages: { count: 0, expected: 2 },
defaultBuiltInPages: { count: 0, expected: 5 },
pages: { created: 0, expected: 5, missing: [] },
homePage: false,
navigation: false,
Expand All @@ -20,7 +21,8 @@ describe('needsProvisioning', () => {
expect(
needsProvisioning(
buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 5, expected: 5, missing: [] },
homePage: true,
navigation: true,
Expand All @@ -34,7 +36,8 @@ describe('needsProvisioning', () => {
expect(
needsProvisioning(
buildStatus({
builtInPages: { count: 3, expected: 7 },
forecastPages: { count: 1, expected: 2 },
defaultBuiltInPages: { count: 2, expected: 5 },
pages: { created: 5, expected: 5, missing: [] },
homePage: true,
navigation: true,
Expand All @@ -48,7 +51,8 @@ describe('needsProvisioning', () => {
expect(
needsProvisioning(
buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] },
homePage: true,
navigation: true,
Expand All @@ -62,7 +66,8 @@ describe('needsProvisioning', () => {
expect(
needsProvisioning(
buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 5, expected: 5, missing: [] },
homePage: false,
navigation: true,
Expand All @@ -76,7 +81,8 @@ describe('needsProvisioning', () => {
expect(
needsProvisioning(
buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 5, expected: 5, missing: [] },
homePage: true,
navigation: false,
Expand All @@ -90,7 +96,8 @@ describe('needsProvisioning', () => {
expect(
needsProvisioning(
buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 5, expected: 5, missing: [] },
homePage: true,
navigation: true,
Expand All @@ -104,7 +111,8 @@ describe('needsProvisioning', () => {
expect(
needsProvisioning(
buildStatus({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
homePage: true,
navigation: true,
settings: { exists: true },
Expand Down
3 changes: 2 additions & 1 deletion __tests__/server/OnboardingStatusCell.server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ function isReactElement(value: unknown): value is React.ReactElement<{
}

const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
builtInPages: { count: 7, expected: 7 },
forecastPages: { count: 2, expected: 2 },
defaultBuiltInPages: { count: 5, expected: 5 },
pages: { created: 5, expected: 5, missing: [] },
homePage: true,
navigation: true,
Expand Down
154 changes: 154 additions & 0 deletions __tests__/server/resolveBuiltInPages.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// TODO: Migrate to MSW for HTTP-level mocking. MSW v2 ships ESM which requires
// transformIgnorePatterns changes in jest.config.mjs (next/jest overrides them).
// See PR #969 (getAvalancheCenterPlatforms test) which uses this same jest.mock
// pattern. Unlike that test which re-implements the function logic with mock data,
// this test mocks at the function boundary since resolveBuiltInPages orchestrates
// multiple NAC calls — MSW would let us test the full call chain end-to-end.
const mockGetActiveForecastZones = jest.fn()
const mockGetAvalancheCenterPlatforms = jest.fn()

jest.mock('../../src/services/nac/nac', () => ({
getActiveForecastZones: (...args: unknown[]) => mockGetActiveForecastZones(...args),
getAvalancheCenterPlatforms: (...args: unknown[]) => mockGetAvalancheCenterPlatforms(...args),
}))

import { resolveBuiltInPages } from '@/collections/Tenants/endpoints/provisionTenant'

// @ts-expect-error - partial mock of pino Logger; only methods used in tests are provided
const mockLog: import('pino').Logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}

const navBuiltInPages = [
{ title: 'All Forecasts', url: '/forecasts/avalanche' },
{ title: 'Mountain Weather', url: '/weather/forecast' },
{ title: 'Weather Stations', url: '/weather/stations/map' },
{ title: 'Recent Observations', url: '/observations' },
{ title: 'Blog', url: '/blog' },
]

function makeZone(name: string, slug: string, rank: number) {
return {
slug,
zone: {
id: Math.floor(Math.random() * 1000),
name,
url: `/forecasts/${slug}`,
zone_id: slug,
config: {
elevation_band_names: {
lower: 'Below Treeline',
middle: 'Near Treeline',
upper: 'Above Treeline',
},
},
status: 'active' as const,
rank,
},
}
}

describe('resolveBuiltInPages', () => {
beforeEach(() => {
jest.clearAllMocks()
mockGetAvalancheCenterPlatforms.mockResolvedValue({ weather: false })
})

describe('forecast pages', () => {
it('creates single forecast page for single-zone center', async () => {
mockGetActiveForecastZones.mockResolvedValue([makeZone('Olympic', 'olympic', 1)])

const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(forecastPages).toEqual([
{ title: 'Avalanche Forecast', url: '/forecasts/avalanche/olympic' },
])
})

it('creates All Forecasts + per-zone pages for multi-zone center sorted by rank', async () => {
mockGetActiveForecastZones.mockResolvedValue([
makeZone('Zone B', 'zone-b', 2),
makeZone('Zone A', 'zone-a', 1),
])

const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(forecastPages).toEqual([
{ title: 'All Forecasts', url: '/forecasts/avalanche' },
{ title: 'Zone A', url: '/forecasts/avalanche/zone-a' },
{ title: 'Zone B', url: '/forecasts/avalanche/zone-b' },
])
})

it('creates default All Forecasts page when AFP returns no zones', async () => {
mockGetActiveForecastZones.mockResolvedValue([])

const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(forecastPages).toEqual([{ title: 'All Forecasts', url: '/forecasts/avalanche' }])
})

it('falls back to default All Forecasts page when AFP fails', async () => {
mockGetActiveForecastZones.mockRejectedValue(new Error('network error'))

const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(forecastPages).toEqual([{ title: 'All Forecasts', url: '/forecasts/avalanche' }])
})
})

describe('non-forecast pages', () => {
beforeEach(() => {
mockGetActiveForecastZones.mockResolvedValue([])
})

it('excludes forecast pages from non-forecast list', async () => {
const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(nonForecastPages.find((p) => p.url.startsWith('/forecasts/avalanche'))).toBeUndefined()
})

it('excludes Mountain Weather when center has no weather platform', async () => {
mockGetAvalancheCenterPlatforms.mockResolvedValue({ weather: false })

const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(nonForecastPages.find((p) => p.url === '/weather/forecast')).toBeUndefined()
})

it('includes Mountain Weather when center has weather platform', async () => {
mockGetAvalancheCenterPlatforms.mockResolvedValue({ weather: true })

const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(nonForecastPages).toContainEqual({
title: 'Mountain Weather',
url: '/weather/forecast',
})
})

it('excludes Mountain Weather when NAC platforms query fails', async () => {
mockGetAvalancheCenterPlatforms.mockRejectedValue(new Error('network error'))

const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(nonForecastPages.find((p) => p.url === '/weather/forecast')).toBeUndefined()
})

it('includes other DVAC nav pages unchanged', async () => {
const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog)

expect(nonForecastPages).toContainEqual({
title: 'Weather Stations',
url: '/weather/stations/map',
})
expect(nonForecastPages).toContainEqual({
title: 'Recent Observations',
url: '/observations',
})
expect(nonForecastPages).toContainEqual({ title: 'Blog', url: '/blog' })
})
})
})
31 changes: 14 additions & 17 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,26 @@ Provisioning is idempotent and can be rerun safely.
| Step | Details |
|------|---------|
| Website Settings | Created with placeholder brand assets (logo, icon, banner). Replace with real assets via the checklist link. |
| Built-in pages | Creates standard pages per the table below. |
| Forecast pages | Queries AFP via `getActiveForecastZones()` to auto-detect single vs multi-zone. Creates zone-specific built-in pages (see table below). Falls back to a default "All Forecasts" page if AFP is unavailable. |
| Default built-in pages | Creates non-forecast built-in pages sourced from the template (DVAC) navigation (see table below). Mountain Weather is only included if the center has a weather forecast configured in NAC (`platforms.weather`). |
| Template pages | Copies all published pages from the template tenant (DVAC). Pages whose blocks all reference tenant-scoped data (teams, sponsors, events, forms) are copied as empty drafts. Demo pages (`blocks`, `lexical-blocks`) are skipped. Static blog/event list blocks are converted to dynamic mode. |
| Home page | Creates a home page with welcome content and quick links to About Us and Donate. |
| Navigation | Creates navigation menus linked to all copied pages and built-in pages. |
| Navigation | Creates navigation menus linked to all copied pages and built-in pages. Forecasts tab is zone-aware (single zone: direct link; multi-zone: "All Forecasts" + per-zone items). |
| Edge Config | The `updateEdgeConfigAfterChange` hook automatically adds the tenant to Vercel Edge Config. |

#### Built-In Pages

\* If a center has a single forecast zone, it gets an "Avalanche Forecast" page pointing to that zone. Multi-zone centers get an "All Forecasts" page plus individual zone pages.

| Title | URL | For AC with single or multi zone* |
|-------|-----|-----------------------------------|
| All Forecasts | `/forecasts/avalanche` | multi |
| _ZONE NAME_ | `/forecasts/avalanche/ZONE` | multi |
| Avalanche Forecast | `/forecasts/avalanche/ZONE` | single |
| Mountain Weather** | `/weather/forecast` | both |
| Weather Stations | `/weather/stations/map` | both |
| Recent Observations | `/observations` | both |
| Submit Observations | `/observations/submit` | both |
| Blog | `/blog` | both |
| Events | `/events` | both |

\*\* Mountain Weather is only available for centers that have a weather forecast configured.
Forecast pages are determined by AFP zone data\*. Non-forecast pages are sourced from the template tenant's (DVAC) navigation — adding or removing a built-in page in DVAC's nav automatically changes what new tenants get.

\* If a center has a single forecast zone, it gets an "Avalanche Forecast" page pointing to that zone. Multi-zone centers get an "All Forecasts" page plus individual zone pages. If AFP is unavailable, a default "All Forecasts" page is created.

| Title | URL | Source |
|-------|-----|--------|
| All Forecasts | `/forecasts/avalanche` | AFP (multi-zone) |
| _ZONE NAME_ | `/forecasts/avalanche/ZONE` | AFP (multi-zone) |
| Avalanche Forecast | `/forecasts/avalanche/ZONE` | AFP (single-zone) |
| Mountain Weather | `/weather/forecast` | NAC `platforms.weather` |
| _Non-forecast pages_ | _varies_ | DVAC navigation |

## Manual steps

Expand Down
Loading
Loading