{!userInfo ? (
<>
-
+
Log in with Google by clicking the “Sign in!” button in the upper
right corner to add your own favorite locations!
@@ -33,12 +33,12 @@ export const WelcomeHomeMsg = () => {
{userInfo ? (
- <>
+
Click the “Edit Locations” button in the upper left corner to add a new
custom location or edit an existing custom location.
- >
+
) : null}
In my experience over the last decade, NOAA's pinpoint forecasts have proven to be
far more accurate for backcountry destinations when compared to the average weather app.
diff --git a/src/app/Util/utils.test.ts b/src/app/Util/utils.test.ts
new file mode 100644
index 0000000..4e8f8dc
--- /dev/null
+++ b/src/app/Util/utils.test.ts
@@ -0,0 +1,107 @@
+import {
+ createGoogleMapPoints,
+ filterAndSortLocationsAlphaByName,
+ findLocByIdInUserLocs,
+ formatPOIDataForDisplay,
+ resetErrorMsg,
+} from './utils';
+import { UserLocation } from '../Classes/UserLocation';
+import { LocationObject } from '../Interfaces/interfaces';
+
+describe('filterAndSortLocationsAlphaByName', () => {
+ const locations: LocationObject[] = [
+ { id: 1, latitude: '40', longitude: '-74', name: 'Zion', poi_type: 'climb' },
+ { id: 2, latitude: '41', longitude: '-75', name: 'Acadia', poi_type: 'climb' },
+ { id: 3, latitude: '42', longitude: '-76', name: 'Mesa', poi_type: 'ski' },
+ { id: 4, latitude: '43', longitude: '-77', name: 'Boulder', poi_type: 'climb' },
+ ];
+
+ it('filters by type and sorts alphabetically', () => {
+ const result = filterAndSortLocationsAlphaByName(locations, 'climb');
+ expect(result.map((l) => l.name)).toEqual(['Acadia', 'Boulder', 'Zion']);
+ });
+
+ it('returns empty array when no locations match the type', () => {
+ const result = filterAndSortLocationsAlphaByName(locations, 'mtb');
+ expect(result).toEqual([]);
+ });
+
+ it('returns empty array when input is empty', () => {
+ const result = filterAndSortLocationsAlphaByName([], 'climb');
+ expect(result).toEqual([]);
+ });
+});
+
+describe('createGoogleMapPoints', () => {
+ it('converts LocationObjects with numeric coercion', () => {
+ const locs: LocationObject[] = [
+ { id: 1, latitude: '40.7128', longitude: '-74.006', name: 'NYC', poi_type: 'other' },
+ ];
+ const result = createGoogleMapPoints(locs);
+ expect(result).toEqual([
+ { coords: { lat: 40.7128, lng: -74.006 }, name: 'NYC', poiType: 'other' },
+ ]);
+ });
+
+ it('converts UserLocation instances', () => {
+ const loc = new UserLocation(1, 'Test', '39.5', '-105.2', 'user-1', 'climb', null, null);
+ const result = createGoogleMapPoints([loc]);
+ expect(result).toHaveLength(1);
+ expect(result[0].coords).toEqual({ lat: 39.5, lng: -105.2 });
+ });
+});
+
+describe('formatPOIDataForDisplay', () => {
+ it.each([
+ ['climb', 'Climbing'],
+ ['mtb', 'Mountain Biking'],
+ ['other', 'Other'],
+ ['ski', 'Skiing'],
+ ])('returns "%s" → "%s"', (input, expected) => {
+ expect(formatPOIDataForDisplay(input)).toBe(expected);
+ });
+
+ it('returns "Unknown" for unrecognized input', () => {
+ expect(formatPOIDataForDisplay('surf')).toBe('Unknown');
+ });
+});
+
+describe('findLocByIdInUserLocs', () => {
+ const loc1 = new UserLocation(1, 'A', '40', '-74', 'u1', 'climb', null, null);
+ const loc2 = new UserLocation(2, 'B', '41', '-75', 'u1', 'ski', null, null);
+
+ it('returns the matching location', () => {
+ expect(findLocByIdInUserLocs(2, [loc1, loc2])).toBe(loc2);
+ });
+
+ it('returns undefined when id does not match', () => {
+ expect(findLocByIdInUserLocs(99, [loc1, loc2])).toBeUndefined();
+ });
+
+ it('returns undefined and logs when array is empty', () => {
+ const spy = jest.spyOn(console, 'log').mockImplementation();
+ expect(findLocByIdInUserLocs(1, [])).toBeUndefined();
+ expect(spy).toHaveBeenCalledWith('Array of userLocations is empty');
+ spy.mockRestore();
+ });
+
+ it('returns undefined and logs when array is null', () => {
+ const spy = jest.spyOn(console, 'log').mockImplementation();
+ expect(findLocByIdInUserLocs(1, null)).toBeUndefined();
+ expect(spy).toHaveBeenCalledWith('Array of userLocations is empty');
+ spy.mockRestore();
+ });
+});
+
+describe('resetErrorMsg', () => {
+ beforeEach(() => jest.useFakeTimers());
+ afterEach(() => jest.useRealTimers());
+
+ it('clears the error message after 1500ms', () => {
+ const setter = jest.fn();
+ resetErrorMsg(setter);
+ expect(setter).not.toHaveBeenCalled();
+ jest.advanceTimersByTime(1500);
+ expect(setter).toHaveBeenCalledWith('');
+ });
+});
diff --git a/src/app/Util/utils.ts b/src/app/Util/utils.ts
index e1ea071..496b5f2 100644
--- a/src/app/Util/utils.ts
+++ b/src/app/Util/utils.ts
@@ -5,18 +5,9 @@ export function filterAndSortLocationsAlphaByName(
locArr: Array,
selectedType: string,
): Array {
- const filteredSortedLocations = locArr
+ return locArr
.filter((loc) => loc.poi_type === selectedType)
- .toSorted((a, b) => {
- if (a.name < b.name) {
- return -1;
- }
- if (a.name > b.name) {
- return 1;
- }
- return 0;
- });
- return filteredSortedLocations;
+ .toSorted((a, b) => a.name.localeCompare(b.name));
}
export const createGoogleMapPoints = (locs: LocationObject[] | UserLocation[]) => {
@@ -25,7 +16,7 @@ export const createGoogleMapPoints = (locs: LocationObject[] | UserLocation[]) =
lat: +location.latitude,
lng: +location.longitude,
};
- return { name: location.name, poiType: location.poi_type, coords: coords };
+ return { name: location.name, poiType: location.poi_type, coords };
});
return points;
};
diff --git a/src/app/home.css b/src/app/home.css
index d667e27..7321e52 100644
--- a/src/app/home.css
+++ b/src/app/home.css
@@ -8,7 +8,8 @@
}
button,
-select {
+select,
+#navLocationBtn {
background-color: transparent;
border: 1px solid #00b2ff;
border-radius: 5px;
@@ -71,11 +72,13 @@ select {
}
#navLocationBtn {
+ animation: fadeIn ease-in 0.5s;
background-color: transparent;
border: 1px solid #00b2ff;
border-radius: 5px;
+ display: inline-block;
padding: 0.5rem;
- animation: fadeIn ease-in 0.5s;
+ text-decoration: none;
}
.desktop-title-controls {
diff --git a/src/app/lib/parseBody.test.ts b/src/app/lib/parseBody.test.ts
new file mode 100644
index 0000000..ad53c1b
--- /dev/null
+++ b/src/app/lib/parseBody.test.ts
@@ -0,0 +1,51 @@
+/**
+ * @jest-environment node
+ */
+import { z } from 'zod';
+
+import { parseBody } from './parseBody';
+
+const testSchema = z.object({
+ name: z.string().min(1),
+ value: z.number(),
+});
+
+function makeRequest(body: unknown): Request {
+ return new Request('http://localhost', {
+ body: typeof body === 'string' ? body : JSON.stringify(body),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ });
+}
+
+describe('parseBody', () => {
+ it('returns parsed data for a valid body', async () => {
+ const result = await parseBody(makeRequest({ name: 'test', value: 42 }), testSchema);
+ expect(result.data).toEqual({ name: 'test', value: 42 });
+ expect(result.error).toBeNull();
+ });
+
+ it('returns 400 error for malformed JSON', async () => {
+ const request = new Request('http://localhost', {
+ body: '{not valid json',
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ });
+ const result = await parseBody(request, testSchema);
+ expect(result.data).toBeNull();
+ expect(result.error).not.toBeNull();
+ expect(result.error!.status).toBe(400);
+ const json = await result.error!.json();
+ expect(json.error).toBe('Invalid JSON body');
+ });
+
+ it('returns 400 error with details for schema mismatch', async () => {
+ const result = await parseBody(makeRequest({ name: '', value: 'not-a-number' }), testSchema);
+ expect(result.data).toBeNull();
+ expect(result.error).not.toBeNull();
+ expect(result.error!.status).toBe(400);
+ const json = await result.error!.json();
+ expect(json.details).toBeDefined();
+ expect(json.error).toBe('Invalid request body');
+ });
+});
diff --git a/src/app/lib/schemas.test.ts b/src/app/lib/schemas.test.ts
new file mode 100644
index 0000000..71a256c
--- /dev/null
+++ b/src/app/lib/schemas.test.ts
@@ -0,0 +1,180 @@
+import {
+ createUserLocationSchema,
+ createUserSchema,
+ deleteUserLocationSchema,
+ patchUserLocationSchema,
+ patchUserSchema,
+ poiTypeSchema,
+ sendScoreRequestSchema,
+} from './schemas';
+
+describe('poiTypeSchema', () => {
+ it.each(['climb', 'mtb', 'other', 'ski'])('accepts "%s"', (val) => {
+ expect(poiTypeSchema.safeParse(val).success).toBe(true);
+ });
+
+ it('rejects invalid type', () => {
+ expect(poiTypeSchema.safeParse('surf').success).toBe(false);
+ });
+});
+
+describe('createUserLocationSchema', () => {
+ const valid = { latitude: '40.7', longitude: '-74.0', name: 'Test', poi_type: 'climb' };
+
+ it('accepts valid data', () => {
+ expect(createUserLocationSchema.safeParse(valid).success).toBe(true);
+ });
+
+ it.each(['latitude', 'longitude', 'name'] as const)('rejects empty %s', (field) => {
+ expect(createUserLocationSchema.safeParse({ ...valid, [field]: '' }).success).toBe(false);
+ });
+
+ it('rejects invalid poi_type', () => {
+ expect(createUserLocationSchema.safeParse({ ...valid, poi_type: 'surf' }).success).toBe(false);
+ });
+
+ it('rejects missing fields', () => {
+ expect(createUserLocationSchema.safeParse({}).success).toBe(false);
+ });
+});
+
+describe('patchUserLocationSchema', () => {
+ it('accepts a name change', () => {
+ const result = patchUserLocationSchema.safeParse({
+ changeCol: 'name',
+ data: 'New Name',
+ id: 1,
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('accepts a poi_type change', () => {
+ const result = patchUserLocationSchema.safeParse({
+ changeCol: 'poi_type',
+ data: 'ski',
+ id: 1,
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects name longer than 50 characters', () => {
+ const result = patchUserLocationSchema.safeParse({
+ changeCol: 'name',
+ data: 'a'.repeat(51),
+ id: 1,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects empty name', () => {
+ const result = patchUserLocationSchema.safeParse({
+ changeCol: 'name',
+ data: '',
+ id: 1,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects invalid poi_type data', () => {
+ const result = patchUserLocationSchema.safeParse({
+ changeCol: 'poi_type',
+ data: 'surf',
+ id: 1,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects invalid changeCol discriminator', () => {
+ const result = patchUserLocationSchema.safeParse({
+ changeCol: 'email',
+ data: 'x',
+ id: 1,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects non-positive id', () => {
+ const result = patchUserLocationSchema.safeParse({
+ changeCol: 'name',
+ data: 'Test',
+ id: 0,
+ });
+ expect(result.success).toBe(false);
+ });
+});
+
+describe('deleteUserLocationSchema', () => {
+ it('accepts valid data', () => {
+ expect(deleteUserLocationSchema.safeParse({ id: 1, user_id: 'u1' }).success).toBe(true);
+ });
+
+ it('rejects non-positive id', () => {
+ expect(deleteUserLocationSchema.safeParse({ id: -1, user_id: 'u1' }).success).toBe(false);
+ });
+
+ it('rejects empty user_id', () => {
+ expect(deleteUserLocationSchema.safeParse({ id: 1, user_id: '' }).success).toBe(false);
+ });
+});
+
+describe('createUserSchema', () => {
+ const valid = { email: 'test@example.com', id: 'u1', name: 'Alice' };
+
+ it('accepts valid data', () => {
+ expect(createUserSchema.safeParse(valid).success).toBe(true);
+ });
+
+ it('rejects invalid email', () => {
+ expect(createUserSchema.safeParse({ ...valid, email: 'not-an-email' }).success).toBe(false);
+ });
+
+ it('rejects empty id', () => {
+ expect(createUserSchema.safeParse({ ...valid, id: '' }).success).toBe(false);
+ });
+
+ it('rejects empty name', () => {
+ expect(createUserSchema.safeParse({ ...valid, name: '' }).success).toBe(false);
+ });
+});
+
+describe('patchUserSchema', () => {
+ it('accepts id with optional email and name', () => {
+ expect(patchUserSchema.safeParse({ id: 'u1' }).success).toBe(true);
+ expect(
+ patchUserSchema.safeParse({ email: 'new@example.com', id: 'u1', name: 'Bob' }).success,
+ ).toBe(true);
+ });
+
+ it('rejects empty id', () => {
+ expect(patchUserSchema.safeParse({ id: '' }).success).toBe(false);
+ });
+
+ it('rejects invalid email when provided', () => {
+ expect(patchUserSchema.safeParse({ email: 'bad', id: 'u1' }).success).toBe(false);
+ });
+});
+
+describe('sendScoreRequestSchema', () => {
+ const valid = {
+ forecastPeriods: [{ detailedForecast: 'Sunny.', name: 'Today' }],
+ sport: 'climb' as const,
+ };
+
+ it('accepts valid data', () => {
+ expect(sendScoreRequestSchema.safeParse(valid).success).toBe(true);
+ });
+
+ it('rejects sport "other"', () => {
+ expect(sendScoreRequestSchema.safeParse({ ...valid, sport: 'other' }).success).toBe(false);
+ });
+
+ it('rejects empty forecastPeriods', () => {
+ expect(sendScoreRequestSchema.safeParse({ ...valid, forecastPeriods: [] }).success).toBe(false);
+ });
+
+ it('rejects missing sport', () => {
+ expect(
+ sendScoreRequestSchema.safeParse({ forecastPeriods: valid.forecastPeriods }).success,
+ ).toBe(false);
+ });
+});
diff --git a/src/test/factories.ts b/src/test/factories.ts
new file mode 100644
index 0000000..55dddd5
--- /dev/null
+++ b/src/test/factories.ts
@@ -0,0 +1,312 @@
+import { User } from '@/app/Classes/User';
+import { UserLocation } from '@/app/Classes/UserLocation';
+import type {
+ ForecastData,
+ HourlyForecastData,
+ LocationDetails,
+} from '@/app/Interfaces/interfaces';
+
+// ── Raw DB row shapes (plain objects, not class instances) ────────────────────
+
+export interface UserRow {
+ date_created: string;
+ email: string;
+ id: string;
+ last_login: string;
+ last_modified: string;
+ name: string;
+}
+
+export interface UserLocationRow {
+ date_created: string;
+ id: number;
+ last_modified: string;
+ latitude: string;
+ longitude: string;
+ name: string;
+ poi_type: string;
+ user_id: string;
+}
+
+// ── User ──────────────────────────────────────────────────────────────────────
+
+export function makeUser(overrides: Partial = {}): User {
+ const defaults: UserRow = {
+ date_created: '2024-01-01T00:00:00.000Z',
+ email: 'test@example.com',
+ id: 'user-test-id',
+ last_login: '2024-01-01T00:00:00.000Z',
+ last_modified: '2024-01-01T00:00:00.000Z',
+ name: 'Test User',
+ };
+ const data = { ...defaults, ...overrides };
+ return new User(
+ data.id,
+ data.email,
+ data.name,
+ data.last_login,
+ data.date_created,
+ data.last_modified,
+ );
+}
+
+export function makeUserRow(overrides: Partial = {}): UserRow {
+ return {
+ date_created: '2024-01-01T00:00:00.000Z',
+ email: 'test@example.com',
+ id: 'user-test-id',
+ last_login: '2024-01-01T00:00:00.000Z',
+ last_modified: '2024-01-01T00:00:00.000Z',
+ name: 'Test User',
+ ...overrides,
+ };
+}
+
+// ── UserLocation ──────────────────────────────────────────────────────────────
+
+export function makeUserLocation(overrides: Partial = {}): UserLocation {
+ const defaults: UserLocationRow = {
+ date_created: '2024-01-01T00:00:00.000Z',
+ id: 1,
+ last_modified: '2024-01-01T00:00:00.000Z',
+ latitude: '40.7128',
+ longitude: '-74.0060',
+ name: 'Test Location',
+ poi_type: 'climb',
+ user_id: 'user-test-id',
+ };
+ const data = { ...defaults, ...overrides };
+ return new UserLocation(
+ data.id,
+ data.name,
+ data.latitude,
+ data.longitude,
+ data.user_id,
+ data.poi_type,
+ data.date_created,
+ data.last_modified,
+ );
+}
+
+export function makeUserLocationRow(overrides: Partial = {}): UserLocationRow {
+ return {
+ date_created: '2024-01-01T00:00:00.000Z',
+ id: 1,
+ last_modified: '2024-01-01T00:00:00.000Z',
+ latitude: '40.7128',
+ longitude: '-74.0060',
+ name: 'Test Location',
+ poi_type: 'climb',
+ user_id: 'user-test-id',
+ ...overrides,
+ };
+}
+
+// ── NOAA: LocationDetails ─────────────────────────────────────────────────────
+
+export function makeLocationDetails(
+ overrides: Partial = {},
+): LocationDetails {
+ return {
+ '@context': [
+ 'https://geojson.org/geojson-ld/geojson-context.jsonld',
+ {
+ '@version': '1.1',
+ '@vocab': 'https://api.weather.gov/ontology#',
+ bearing: { '@type': 'xsd:integer' },
+ city: 's:addressLocality',
+ county: { '@type': '@id' },
+ distance: { '@id': 's:distance', '@type': 's:QuantitativeValue' },
+ forecastGridData: { '@type': '@id' },
+ forecastOffice: { '@type': '@id' },
+ geo: 'http://www.opengis.net/ont/geosparql#',
+ geometry: {
+ '@id': 's:GeoCoordinates',
+ '@type': 'geo:wktLiteral',
+ },
+ publicZone: { '@type': '@id' },
+ s: 'https://schema.org/',
+ state: 's:addressRegion',
+ unit: 'http://codes.wmo.int/common/unit/',
+ unitCode: {
+ '@id': 's:unitCode',
+ '@type': '@id',
+ },
+ value: { '@id': 's:value' },
+ wx: 'https://api.weather.gov/ontology#',
+ },
+ ],
+ geometry: {
+ coordinates: [-74.006, 40.7128],
+ type: 'Point',
+ },
+ id: 'https://api.weather.gov/points/40.7128,-74.0060',
+ properties: {
+ '@id': 'https://api.weather.gov/points/40.7128,-74.0060',
+ '@type': 'wx:Point',
+ county: 'https://api.weather.gov/zones/county/NYC061',
+ cwa: 'OKX',
+ fireWeatherZone: 'https://api.weather.gov/zones/fire/NYZ212',
+ forecast: 'https://api.weather.gov/gridpoints/OKX/33,37/forecast',
+ forecastGridData: 'https://api.weather.gov/gridpoints/OKX/33,37',
+ forecastHourly: 'https://api.weather.gov/gridpoints/OKX/33,37/forecast/hourly',
+ forecastOffice: 'https://api.weather.gov/offices/OKX',
+ forecastZone: 'https://api.weather.gov/zones/forecast/NYZ072',
+ gridId: 'OKX',
+ gridX: 33,
+ gridY: 37,
+ observationStations: 'https://api.weather.gov/gridpoints/OKX/33,37/stations',
+ radarStation: 'KOKX',
+ relativeLocation: {
+ geometry: {
+ coordinates: [-74.006, 40.7128],
+ type: 'Point',
+ },
+ properties: {
+ bearing: { unitCode: 'wmoUnit:degree_(angle)', value: 0 },
+ city: 'Test City',
+ distance: { unitCode: 'wmoUnit:km', value: 0 },
+ state: 'NY',
+ },
+ type: 'Feature',
+ },
+ timeZone: 'America/New_York',
+ ...overrides,
+ },
+ type: 'Feature',
+ };
+}
+
+// ── NOAA: ForecastData (daily) ────────────────────────────────────────────────
+
+export function makeForecastPeriod(
+ overrides: Partial = {},
+): ForecastData['properties']['periods'][number] {
+ return {
+ detailedForecast: 'Sunny with a high near 75.',
+ dewpoint: { unitCode: 'wmoUnit:degC', value: 10 },
+ endTime: '2024-06-15T18:00:00-04:00',
+ icon: 'https://api.weather.gov/icons/land/day/skc?size=medium',
+ isDaytime: true,
+ name: 'Today',
+ number: 1,
+ probabilityOfPrecipitation: { unitCode: 'wmoUnit:percent', value: 10 },
+ relativeHumidity: { unitCode: 'wmoUnit:percent', value: 55 },
+ shortForecast: 'Sunny',
+ startTime: '2024-06-15T06:00:00-04:00',
+ temperature: 75,
+ temperatureTrend: null,
+ temperatureUnit: 'F',
+ windDirection: 'NW',
+ windSpeed: '10 mph',
+ ...overrides,
+ };
+}
+
+export function makeForecastData(
+ overrides: Partial = {},
+): ForecastData {
+ return {
+ '@context': [
+ 'https://geojson.org/geojson-ld/geojson-context.jsonld',
+ {
+ '@version': '1.1',
+ '@vocab': 'https://api.weather.gov/ontology#',
+ geo: 'http://www.opengis.net/ont/geosparql#',
+ unit: 'http://codes.wmo.int/common/unit/',
+ wx: 'https://api.weather.gov/ontology#',
+ },
+ ],
+ geometry: {
+ coordinates: [[[-74.006, 40.7128]]],
+ type: 'Polygon',
+ },
+ properties: {
+ elevation: { unitCode: 'wmoUnit:m', value: 10 },
+ forecastGenerator: 'BaselineForecastGenerator',
+ generatedAt: '2024-06-15T10:00:00+00:00',
+ periods: [
+ makeForecastPeriod({ isDaytime: true, name: 'Today', number: 1 }),
+ makeForecastPeriod({
+ isDaytime: false,
+ name: 'Tonight',
+ number: 2,
+ startTime: '2024-06-15T18:00:00-04:00',
+ temperature: 58,
+ }),
+ ],
+ units: 'us',
+ updateTime: '2024-06-15T09:30:00+00:00',
+ updated: '2024-06-15T09:30:00+00:00',
+ validTimes: '2024-06-15T09:00:00+00:00/P7DT3H',
+ ...overrides,
+ },
+ type: 'Feature',
+ };
+}
+
+// ── NOAA: HourlyForecastData ──────────────────────────────────────────────────
+
+export function makeHourlyForecastPeriod(
+ overrides: Partial = {},
+): HourlyForecastData['properties']['periods'][number] {
+ return {
+ detailedForecast: '',
+ dewpoint: { unitCode: 'wmoUnit:degC', value: 10 },
+ endTime: '2024-06-15T07:00:00-04:00',
+ icon: 'https://api.weather.gov/icons/land/day/skc?size=small',
+ isDaytime: true,
+ name: '',
+ number: 1,
+ probabilityOfPrecipitation: { unitCode: 'wmoUnit:percent', value: 5 },
+ relativeHumidity: { unitCode: 'wmoUnit:percent', value: 60 },
+ shortForecast: 'Clear',
+ startTime: '2024-06-15T06:00:00-04:00',
+ temperature: 68,
+ temperatureTrend: null,
+ temperatureUnit: 'F',
+ windDirection: 'N',
+ windSpeed: '5 mph',
+ ...overrides,
+ };
+}
+
+export function makeHourlyForecastData(
+ overrides: Partial = {},
+): HourlyForecastData {
+ return {
+ '@context': [
+ 'https://geojson.org/geojson-ld/geojson-context.jsonld',
+ {
+ '@version': '1.1',
+ '@vocab': 'https://api.weather.gov/ontology#',
+ geo: 'http://www.opengis.net/ont/geosparql#',
+ unit: 'http://codes.wmo.int/common/unit/',
+ wx: 'https://api.weather.gov/ontology#',
+ },
+ ],
+ geometry: {
+ coordinates: [[[-74.006, 40.7128]]],
+ type: 'Polygon',
+ },
+ properties: {
+ elevation: { unitCode: 'wmoUnit:m', value: 10 },
+ forecastGenerator: 'HourlyForecastGenerator',
+ generatedAt: '2024-06-15T10:00:00+00:00',
+ periods: Array.from({ length: 12 }, (_, i) =>
+ makeHourlyForecastPeriod({
+ endTime: `2024-06-15T${String(7 + i).padStart(2, '0')}:00:00-04:00`,
+ number: i + 1,
+ startTime: `2024-06-15T${String(6 + i).padStart(2, '0')}:00:00-04:00`,
+ temperature: 68 + i,
+ }),
+ ),
+ units: 'us',
+ updateTime: '2024-06-15T09:30:00+00:00',
+ updated: '2024-06-15T09:30:00+00:00',
+ validTimes: '2024-06-15T09:00:00+00:00/P7DT3H',
+ ...overrides,
+ },
+ type: 'Feature',
+ };
+}
diff --git a/src/test/handlers.ts b/src/test/handlers.ts
new file mode 100644
index 0000000..1c38f75
--- /dev/null
+++ b/src/test/handlers.ts
@@ -0,0 +1,72 @@
+import { http, HttpResponse } from 'msw';
+
+import {
+ makeForecastData,
+ makeHourlyForecastData,
+ makeLocationDetails,
+ makeUserLocationRow,
+ makeUserRow,
+} from './factories';
+
+export const handlers = [
+ // ── NOAA ─────────────────────────────────────────────────────────────────
+
+ http.get('https://api.weather.gov/points/:coords', () =>
+ HttpResponse.json(makeLocationDetails()),
+ ),
+
+ http.get('https://api.weather.gov/gridpoints/:office/:gridXY/forecast', () =>
+ HttpResponse.json(makeForecastData()),
+ ),
+
+ http.get('https://api.weather.gov/gridpoints/:office/:gridXY/forecast/hourly', () =>
+ HttpResponse.json(makeHourlyForecastData()),
+ ),
+
+ // ── Internal: default_locations ───────────────────────────────────────────
+
+ http.get('/api/default_locations', () =>
+ HttpResponse.json([
+ {
+ id: 1,
+ latitude: '40.7128',
+ longitude: '-74.0060',
+ name: 'New York City',
+ poi_type: 'other',
+ },
+ ]),
+ ),
+
+ // ── Internal: user_locations ──────────────────────────────────────────────
+
+ http.get('/api/user_locations', () => HttpResponse.json([makeUserLocationRow()])),
+
+ http.post('/api/user_locations', () =>
+ HttpResponse.json(makeUserLocationRow({ id: 99 }), { status: 201 }),
+ ),
+
+ http.patch('/api/user_locations', () =>
+ HttpResponse.json({ message: 'Location updated successfully.' }),
+ ),
+
+ http.delete('/api/user_locations', () =>
+ HttpResponse.json({ message: 'Location deleted successfully.' }),
+ ),
+
+ // ── Internal: users ───────────────────────────────────────────────────────
+
+ http.get('/api/users', () => HttpResponse.json(makeUserRow())),
+
+ http.post('/api/users', () => HttpResponse.json(makeUserRow(), { status: 201 })),
+
+ http.patch('/api/users', () => HttpResponse.json({ message: 'User updated successfully.' })),
+
+ // ── Internal: open_ai/send_score ──────────────────────────────────────────
+
+ http.post('/api/open_ai/send_score', () =>
+ HttpResponse.json({
+ forecastPeriods: [{ name: 'Today', sendScore: 80 }],
+ summary: 'Great conditions for outdoor activity.',
+ }),
+ ),
+];
diff --git a/src/test/server.ts b/src/test/server.ts
new file mode 100644
index 0000000..5ac9204
--- /dev/null
+++ b/src/test/server.ts
@@ -0,0 +1,5 @@
+import { setupServer } from 'msw/node';
+
+import { handlers } from './handlers';
+
+export const server = setupServer(...handlers);
diff --git a/tsconfig.json b/tsconfig.json
index cea3a2a..3f743d4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -24,5 +24,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "exclude": ["cypress", "cypress.config.ts", "node_modules"]
}