Skip to content
Merged
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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
pull_request:
push:
branches:
- main

jobs:
test:
name: Test and Coverage
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run tests with coverage
run: npm run test:coverage
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.11.0
20
2 changes: 1 addition & 1 deletion app/about/page.js → app/about/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,4 @@ export default function AboutPage() {
<Footer />
</>
);
}
}
1 change: 1 addition & 0 deletions app/api/conditions/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const postHandler = async (req) => {
const date = ('0' + now.getDate()).slice(-2);
const todayDate = `${year}-${month}-${date}`;
const openWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&units=imperial&appid=${process.env.OW_API_KEY}`;
const airNowUrl = `https://www.airnowapi.org/aq/forecast/latLong/?format=application/json&latitude=${latitude}&longitude=${longitude}&date=${todayDate}&distance=25&API_KEY=${process.env.AN_API_KEY}`;

const [weatherResult, airResult] = await Promise.all([
fetchJsonWithTimeout(openWeatherUrl, 'OpenWeather'),
Expand Down
204 changes: 204 additions & 0 deletions app/api/routes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import * as Sentry from '@sentry/nextjs'
import { POST as conditionsPost } from './conditions/route'
import { POST as getCoordinatesPost } from './getcoordinates/route'
import { POST as airQualityPost } from './airquality/route'
import { POST as weatherPost } from './weather/route'
import { POST as weatherOnlyPost } from './_weather/route'

vi.mock('@sentry/nextjs', () => ({
captureException: vi.fn(),
}))

function jsonRequest(body) {
return new Request('http://localhost', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}

describe('API routes', () => {
beforeEach(() => {
vi.restoreAllMocks()
})

describe('/api/conditions', () => {
it('returns 400 when coordinates are missing', async () => {
const response = await conditionsPost(jsonRequest({}))
expect(response.status).toBe(400)
})

it('returns merged weather and air data when both services succeed', async () => {
const fetchMock = vi.spyOn(global, 'fetch')
fetchMock
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({ wind: { speed: 5 }, name: 'Boise' }),
})
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue([{ AQI: 42 }]),
})

const response = await conditionsPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data.weather).toEqual({ wind: { speed: 5 }, name: 'Boise' })
expect(data.air).toEqual([{ AQI: 42 }])
expect(data.degraded).toBe(false)
expect(fetchMock).toHaveBeenCalledTimes(2)
})

it('returns degraded mode when air service fails but weather succeeds', async () => {
const fetchMock = vi.spyOn(global, 'fetch')
fetchMock
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({ wind: { speed: 5 }, name: 'Boise' }),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({}),
})

const response = await conditionsPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data.degraded).toBe(true)
expect(data.warning).toMatch(/Air quality data is currently unavailable/i)
expect(Sentry.captureException).toHaveBeenCalled()
})

it('returns 502 when weather service fails', async () => {
const fetchMock = vi.spyOn(global, 'fetch')
fetchMock
.mockResolvedValueOnce({
ok: false,
status: 503,
json: vi.fn().mockResolvedValue({}),
})
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue([{ AQI: 10 }]),
})

const response = await conditionsPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
expect(response.status).toBe(502)
})
})

describe('/api/getcoordinates', () => {
it('returns 400 with missing params', async () => {
const response = await getCoordinatesPost(jsonRequest({}))
expect(response.status).toBe(400)
})

it('calls zip endpoint and returns payload', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ lat: 43.5, lon: -116.5 }),
})

const response = await getCoordinatesPost(jsonRequest({ zipcode: '83709' }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ lat: 43.5, lon: -116.5 })
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/geo/1.0/zip?zip=83709'))
})

it('returns 502 on upstream error', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({}),
})

const response = await getCoordinatesPost(jsonRequest({ city: 'Boise,ID' }))
expect(response.status).toBe(502)
})
})

describe('/api/airquality', () => {
it('returns 400 when coordinates are missing', async () => {
const response = await airQualityPost(jsonRequest({}))
expect(response.status).toBe(400)
})

it('returns first AQI record on success', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([{ AQI: 55 }, { AQI: 56 }]),
})

const response = await airQualityPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ AQI: 55 })
})

it('returns 502 when upstream payload is unexpected', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
})

const response = await airQualityPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
expect(response.status).toBe(502)
})
})

describe('/api/weather', () => {
it('returns 400 when coordinates are missing', async () => {
const response = await weatherPost(jsonRequest({}))
expect(response.status).toBe(400)
})

it('returns weather and air data on success', async () => {
const fetchMock = vi.spyOn(global, 'fetch')
fetchMock
.mockResolvedValueOnce({ json: vi.fn().mockResolvedValue({ wind: { speed: 4 } }) })
.mockResolvedValueOnce({ json: vi.fn().mockResolvedValue({ list: [{ main: { aqi: 2 } }] }) })

const response = await weatherPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data.weather).toEqual({ wind: { speed: 4 } })
expect(data.air).toEqual({ list: [{ main: { aqi: 2 } }] })
})
})

describe('/api/_weather', () => {
it('returns 400 when coordinates are missing', async () => {
const response = await weatherOnlyPost(jsonRequest({}))
expect(response.status).toBe(400)
})

it('returns weather payload on success', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ name: 'Boise' }),
})

const response = await weatherOnlyPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ name: 'Boise' })
})

it('returns 502 on upstream error', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({}),
})

const response = await weatherOnlyPost(jsonRequest({ latitude: 43.5, longitude: -116.5 }))
expect(response.status).toBe(502)
})
})
})
2 changes: 1 addition & 1 deletion app/contact/page.js → app/contact/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ export default function ContactPage() {
<Footer />
</>
);
}
}
39 changes: 39 additions & 0 deletions app/error-pages.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as Sentry from '@sentry/nextjs'
import ErrorPage from './error'
import GlobalErrorPage from './global-error'

vi.mock('@sentry/nextjs', () => ({
captureException: vi.fn(),
}))

describe('error pages', () => {
it('renders route error and allows retry', async () => {
const user = userEvent.setup()
const reset = vi.fn()
const error = new Error('Route blew up')
render(<ErrorPage error={error} reset={reset} />)

expect(screen.getByText('Something went wrong')).toBeInTheDocument()
expect(screen.getByText('Route blew up')).toBeInTheDocument()
expect(Sentry.captureException).toHaveBeenCalledWith(error)

await user.click(screen.getByRole('button', { name: 'Try again' }))
expect(reset).toHaveBeenCalledTimes(1)
})

it('renders global error and allows retry', async () => {
const user = userEvent.setup()
const reset = vi.fn()
const error = new Error('Global blew up')
render(<GlobalErrorPage error={error} reset={reset} />)

expect(screen.getByText('Something went wrong')).toBeInTheDocument()
expect(screen.getByText('Global blew up')).toBeInTheDocument()
expect(Sentry.captureException).toHaveBeenCalledWith(error)

await user.click(screen.getByRole('button', { name: 'Try again' }))
expect(reset).toHaveBeenCalledTimes(1)
})
})
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion app/layout.js → app/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ export default function RootLayout({ children }) {
</body>
</html>
)
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading
Loading