mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-07-27 23:59:50 -07:00
Release 2.35.0 (#4072)
## Release Notes Thanks to: @angeldeejay, @in-voker, @JHWelch, @khassel, @KristjanESPERANTO, @rejas, @sdetweil > ⚠️ This release needs nodejs version >=22.21.1 <23 || >=24 (no change to previous release) [Compare to previous Release v2.34.0](https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.34.0...v2.25.0) > ⚠️ We introduced some internal changes with this release, please read [this forum post](https://forum.magicmirror.builders/topic/20138/upcoming-release-april-1-2026-breaking-changes-some-operational-changes) before upgrading! ### [core] - Prepare Release 2.35.0 (#4071) - docs: add security policy and vulnerability reporting guidelines (#4069) - refactor: simplify internal `require()` calls (#4056) - allow environment variables in cors urls (#4033) - fix cors proxy getting binary data (e.g. png, webp) (#4030) - fix: correct secret redaction and optimize loadConfig (#4031) - change loading config.js, allow variables in config.js and try to protect sensitive data (#4029) - remove kioskmode (#4027) - Add dark theme logo (#4026) - move custom.css from css to config (#4020) - move default modules from /modules/default to /defaultmodules (#4019) - update node versions in workflows (#4018) - [core] refactor: extract and centralize HTTP fetcher (#4016) - fix systeminformation not displaying electron version (#4012) - Update node-ical and support it's rrule-temporal changes (#4010) - Change default start scripts from X11 to Wayland (#4011) - refactor: unify favicon for index.html and Electron (#4006) - [core] run systeminformation in subprocess so the info is always displayed (#4002) - set next release dev number (#4000) ### [dependencies] - update dependencies (#4068) - update dependencies incl. electron to v41 (#4058) - chore: upgrade ESLint to v10 and fix newly surfaced issues (#4057) - chore: update ESLint and plugins, simplify config, apply new rules (#4052) - chore: update dependencies + add exports, files, and sideEffects fields to package.json (#4040) - [core] refactor: enable ESLint rule require-await and handle detected issues (#4038) - Update node-ical and other deps (#4025) - chore: update dependencies (#4021) - chore(eslint): migrate from eslint-plugin-vitest to @vitest/eslint-plugin and run rules only on test files (#4014) - Update deps as requested by dependabot (#4008) - update Collaboration.md and dependencies (#4001) ### [logging] - refactor: further logger clean-up (#4050) - Fix Node.js v25 logging prefix and modernize logger (#4049) ### [modules/calendar] - fix(calendar): make showEnd behavior more consistent across time formats (#4059) - test(calendar): fix hardcoded date in event shape test (#4055) - [calendar] refactor: delegate event expansion to node-ical's expandRecurringEvent (#4047) - calendar.js: remove useless hasCalendarURL function (#4028) - fix(calendar): update to node-ical 0.23.1 and fix full-day recurrence lookup (#4013) - fix(calendar): correct day-of-week for full-day recurring events across all timezones (#4004) ### [modules/newsfeed] - fix(newsfeed): fix full article view and add framing check (#4039) - [newsfeed] refactor: migrate to centralized HTTPFetcher (#4023) ### [modules/weather] - fix(weather): fix openmeteo forecast stuck in the past (#4064) - fix(weather): fix weathergov forecast day labels off by one (#4065) - weather: fixes for templates (#4054) - weather: add possibility to override njk's and css (#4051) - Use getDateString in openmeteo (#4046) - [weather] refactor: migrate to server-side providers with centralized HTTPFetcher (#4032) - [weather] feat: add Weather API Provider (#4036) ### [testing] - chore: remove obsolete Jest config and unit test global setup (#4044) - replace template_spec test with config_variables test (#4034) - refactor(clientonly): modernize code structure and add comprehensive tests (#4022) - Switch to undici Agent for HTTPS requests (#4015) - chore: migrate CI workflows to ubuntu-slim for faster startup times (#4007) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: sam detweiler <sdetweil@gmail.com> Co-authored-by: Veeck <github@veeck.de> Co-authored-by: veeck <gitkraken@veeck.de> Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com> Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com> Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com> Co-authored-by: Nathan <n8nyoung@gmail.com> Co-authored-by: mixasgr <mixasgr@users.noreply.github.com> Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr> Co-authored-by: Konstantinos <geraki@gmail.com> Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com> Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com> Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com> Co-authored-by: Koen Konst <koenspero@gmail.com> Co-authored-by: Koen Konst <c.h.konst@avisi.nl> Co-authored-by: dathbe <github@beffa.us> Co-authored-by: Marcel <m-idler@users.noreply.github.com> Co-authored-by: Kevin G. <crazylegstoo@gmail.com> Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com> Co-authored-by: Jboucly <contact@jboucly.fr> Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com> Co-authored-by: Jordan Welch <JordanHWelch@gmail.com> Co-authored-by: Blackspirits <blackspirits@gmail.com> Co-authored-by: Samed Ozdemir <samed@xsor.io> Co-authored-by: in-voker <58696565+in-voker@users.noreply.github.com> Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com>
This commit is contained in:
co-authored by
Kristjan ESPERANTO
Bugsounet - Cédric
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
sam detweiler
Veeck
veeck
Magnus
Ikko Eltociear Ashimine
DevIncomin
Nathan
mixasgr
Savvas Adamtziloglou
Konstantinos
OWL4C
BugHaver
BugHaver
Koen Konst
Koen Konst
dathbe
Marcel
Kevin G.
Jboucly
Jboucly
Jarno
Jordan Welch
Blackspirits
Samed Ozdemir
in-voker
Andrés Vanegas Jiménez
parent
b742e839be
commit
d05ea751d9
@@ -1,6 +1,8 @@
|
||||
global.moment = require("moment-timezone");
|
||||
|
||||
const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils");
|
||||
const defaults = require("../../../js/defaults");
|
||||
|
||||
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
|
||||
|
||||
describe("Calendar fetcher utils test", () => {
|
||||
const defaultConfig = {
|
||||
|
||||
@@ -2,7 +2,9 @@ global.moment = require("moment-timezone");
|
||||
|
||||
const ical = require("node-ical");
|
||||
const moment = require("moment-timezone");
|
||||
const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils");
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
|
||||
|
||||
describe("Calendar fetcher utils test", () => {
|
||||
const defaultConfig = {
|
||||
@@ -37,12 +39,19 @@ describe("Calendar fetcher utils test", () => {
|
||||
const yesterday = moment().subtract(1, "days").startOf("day").toDate();
|
||||
const today = moment().startOf("day").toDate();
|
||||
const tomorrow = moment().add(1, "days").startOf("day").toDate();
|
||||
const dayAfterTomorrow = moment().add(2, "days").startOf("day").toDate();
|
||||
// Mark as DATE-only (full-day) events per ICS convention
|
||||
yesterday.dateOnly = true;
|
||||
today.dateOnly = true;
|
||||
tomorrow.dateOnly = true;
|
||||
dayAfterTomorrow.dateOnly = true;
|
||||
|
||||
// ICS convention: DTEND for a full-day event is the exclusive next day
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
pastEvent: { type: "VEVENT", start: yesterday, end: yesterday, summary: "pastEvent" },
|
||||
ongoingEvent: { type: "VEVENT", start: today, end: today, summary: "ongoingEvent" },
|
||||
upcomingEvent: { type: "VEVENT", start: tomorrow, end: tomorrow, summary: "upcomingEvent" }
|
||||
pastEvent: { type: "VEVENT", start: yesterday, end: today, summary: "pastEvent" },
|
||||
ongoingEvent: { type: "VEVENT", start: today, end: tomorrow, summary: "ongoingEvent" },
|
||||
upcomingEvent: { type: "VEVENT", start: tomorrow, end: dayAfterTomorrow, summary: "upcomingEvent" }
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
@@ -52,6 +61,58 @@ describe("Calendar fetcher utils test", () => {
|
||||
expect(filteredEvents[1].title).toBe("upcomingEvent");
|
||||
});
|
||||
|
||||
it("should hide excluded event with 'until' when far away and show it when close", () => {
|
||||
// An event ending in 10 days with until='3 days' should be hidden now
|
||||
const farStart = moment().add(9, "days").toDate();
|
||||
const farEnd = moment().add(10, "days").toDate();
|
||||
// An event ending in 1 day with until='3 days' should be shown (within 3 days of end)
|
||||
const closeStart = moment().add(1, "hours").toDate();
|
||||
const closeEnd = moment().add(1, "days").toDate();
|
||||
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
excludedEvents: [{ filterBy: "Payment", until: "3 days" }]
|
||||
};
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
farPayment: { type: "VEVENT", start: farStart, end: farEnd, summary: "Payment due" },
|
||||
closePayment: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Payment reminder" },
|
||||
normalEvent: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Normal event" }
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
// farPayment should be hidden (now < endDate - 3 days)
|
||||
// closePayment should show (now >= endDate - 3 days)
|
||||
// normalEvent should show (not matched by filter)
|
||||
const titles = filteredEvents.map((e) => e.title);
|
||||
expect(titles).not.toContain("Payment due");
|
||||
expect(titles).toContain("Payment reminder");
|
||||
expect(titles).toContain("Normal event");
|
||||
});
|
||||
|
||||
it("should fully exclude event when excludedEvents has no 'until'", () => {
|
||||
const start = moment().add(1, "hours").toDate();
|
||||
const end = moment().add(2, "hours").toDate();
|
||||
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
excludedEvents: ["Hidden"]
|
||||
};
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
hidden: { type: "VEVENT", start, end, summary: "Hidden event" },
|
||||
visible: { type: "VEVENT", start, end, summary: "Visible event" }
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(1);
|
||||
expect(filteredEvents[0].title).toBe("Visible event");
|
||||
});
|
||||
|
||||
it("should return the correct times when recurring events pass through daylight saving time", () => {
|
||||
const data = ical.parseICS(`BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Amsterdam:20250311T090000
|
||||
@@ -92,24 +153,348 @@ DTSTART;TZID=Europe/Amsterdam:20250311T090000
|
||||
DTEND;TZID=Europe/Amsterdam:20250311T091500
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
|
||||
DTSTAMP:20250531T091103Z
|
||||
ORGANIZER;CN=test:mailto:test@test.com
|
||||
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
|
||||
CREATED:20230111T114612Z
|
||||
DESCRIPTION:Test
|
||||
LAST-MODIFIED:20250528T071312Z
|
||||
SEQUENCE:1
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Test
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT`);
|
||||
|
||||
const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days"));
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days"));
|
||||
|
||||
const januaryFirst = moments.filter((m) => m.format("MM-DD") === "01-01");
|
||||
const julyFirst = moments.filter((m) => m.format("MM-DD") === "07-01");
|
||||
const januaryFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "01-01");
|
||||
const julyFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "07-01");
|
||||
|
||||
expect(januaryFirst[0].toISOString(true)).toContain("09:00:00.000+01:00");
|
||||
expect(julyFirst[0].toISOString(true)).toContain("09:00:00.000+02:00");
|
||||
// The underlying timestamps must represent 09:00 Amsterdam time, regardless of local timezone
|
||||
expect(januaryFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+01:00");
|
||||
expect(julyFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+02:00");
|
||||
});
|
||||
|
||||
it("should return correct day-of-week for full-day recurring events across DST transitions", () => {
|
||||
// Test case for GitHub issue #3976: recurring full-day events showing on wrong day
|
||||
// This happens when DST transitions change the UTC offset between occurrences
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20251027
|
||||
DTEND;VALUE=DATE:20251028
|
||||
RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3
|
||||
DTSTAMP:20260103T123138Z
|
||||
UID:dst-test@google.com
|
||||
SUMMARY:Weekly Monday Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
// Simulate calendar with timezone (e.g., from X-WR-TIMEZONE or user config)
|
||||
// This is how MagicMirror handles full-day events from calendars with timezones
|
||||
data["dst-test@google.com"].start.tz = "America/Chicago";
|
||||
|
||||
const pastMoment = moment("2025-10-01");
|
||||
const futureMoment = moment("2025-11-30");
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(data["dst-test@google.com"], pastMoment, futureMoment);
|
||||
const startMoments = instances.map((i) => i.startMoment);
|
||||
|
||||
// All occurrences should be on Monday (day() === 1) at midnight
|
||||
// Oct 27, 2025 - Before DST ends
|
||||
// Nov 3, 2025 - After DST ends (this was showing as Sunday before the fix)
|
||||
// Nov 10, 2025 - After DST ends
|
||||
expect(startMoments).toHaveLength(3);
|
||||
expect(startMoments[0].day()).toBe(1); // Monday
|
||||
expect(startMoments[0].format("YYYY-MM-DD")).toBe("2025-10-27");
|
||||
expect(startMoments[0].hour()).toBe(0); // Midnight
|
||||
expect(startMoments[1].day()).toBe(1); // Monday (not Sunday!)
|
||||
expect(startMoments[1].format("YYYY-MM-DD")).toBe("2025-11-03");
|
||||
expect(startMoments[1].hour()).toBe(0); // Midnight
|
||||
expect(startMoments[2].day()).toBe(1); // Monday
|
||||
expect(startMoments[2].format("YYYY-MM-DD")).toBe("2025-11-10");
|
||||
expect(startMoments[2].hour()).toBe(0); // Midnight
|
||||
});
|
||||
|
||||
it("should show Facebook birthday events in the current year, not in the birth year", () => {
|
||||
// Facebook birthday calendars use DTSTART with the actual birth year (e.g. 1990),
|
||||
// which previously caused rrule.js to return the wrong year occurrence.
|
||||
// With rrule-temporal this works correctly without any special-casing.
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:19900215
|
||||
RRULE:FREQ=YEARLY
|
||||
DTSTAMP:20260101T000000Z
|
||||
UID:birthday_123456789@facebook.com
|
||||
SUMMARY:Jane Doe's Birthday
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const thisYear = moment().year();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(data, {
|
||||
...defaultConfig,
|
||||
maximumNumberOfDays: 366
|
||||
});
|
||||
|
||||
const birthdayEvents = filteredEvents.filter((e) => e.title === "Jane Doe's Birthday");
|
||||
expect(birthdayEvents.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The event must expand to a recent year — NOT to the birth year 1990.
|
||||
// It should be the current or next year depending on whether Feb 15 has already passed.
|
||||
const startYear = moment(birthdayEvents[0].startDate, "x").year();
|
||||
expect(startYear).toBeGreaterThanOrEqual(thisYear);
|
||||
expect(startYear).toBeLessThanOrEqual(thisYear + 1);
|
||||
});
|
||||
|
||||
it("should produce a correctly shaped event object with all required fields", () => {
|
||||
const start = moment().add(1, "day").startOf("hour").toDate();
|
||||
const end = moment().add(1, "day").startOf("hour").add(1, "hour")
|
||||
.toDate();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
event1: {
|
||||
type: "VEVENT",
|
||||
start,
|
||||
end,
|
||||
summary: "Team Meeting",
|
||||
description: "Agenda TBD",
|
||||
location: "Room 42",
|
||||
geo: { lat: 52.52, lon: 13.4 },
|
||||
class: "PUBLIC",
|
||||
uid: "shaped-event@test"
|
||||
}
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(1);
|
||||
const ev = filteredEvents[0];
|
||||
expect(ev.title).toBe("Team Meeting");
|
||||
expect(ev.startDate).toBe(moment(start).format("x"));
|
||||
expect(ev.endDate).toBe(moment(end).format("x"));
|
||||
expect(ev.fullDayEvent).toBe(false);
|
||||
expect(ev.recurringEvent).toBe(false);
|
||||
expect(ev.class).toBe("PUBLIC");
|
||||
expect(ev.firstYear).toBe(moment(start).year());
|
||||
expect(ev.location).toBe("Room 42");
|
||||
expect(ev.geo).toEqual({ lat: 52.52, lon: 13.4 });
|
||||
expect(ev.description).toBe("Agenda TBD");
|
||||
});
|
||||
|
||||
it("should return correct firstYear for a full-day event on January 1st", () => {
|
||||
// node-ical creates DATE-only events with the local Date constructor: new Date(year, month, day).
|
||||
// getFullYear() on a locally-constructed date always returns the correct calendar year
|
||||
// regardless of the server's UTC offset — guard against regressions that switch to getUTCFullYear().
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:19900101
|
||||
DTEND;VALUE=DATE:19900102
|
||||
RRULE:FREQ=YEARLY
|
||||
UID:newyear-birthday@test
|
||||
SUMMARY:New Year Baby
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(data, {
|
||||
...defaultConfig,
|
||||
maximumNumberOfDays: 366
|
||||
});
|
||||
|
||||
const birthday = filteredEvents.find((e) => e.title === "New Year Baby");
|
||||
expect(birthday).toBeDefined();
|
||||
expect(birthday.firstYear).toBe(1990);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandRecurringEvent", () => {
|
||||
it("should extend end to end-of-day when event has no DTEND", () => {
|
||||
// node-ical sets end === start when DTEND is absent; our code extends to endOf("day")
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260222T100000Z
|
||||
UID:no-end-test@test
|
||||
SUMMARY:No End Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(data["no-end-test@test"], moment("2026-02-20"), moment("2026-02-24"));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].endMoment.format("HH:mm:ss")).toBe("23:59:59");
|
||||
});
|
||||
|
||||
it("should apply RECURRENCE-ID overrides (moved single occurrence)", () => {
|
||||
// A weekly event on Mondays at 10:00, but the second occurrence is moved to Tuesday 14:00
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260302T100000
|
||||
DTEND;TZID=Europe/Berlin:20260302T110000
|
||||
RRULE:FREQ=WEEKLY;COUNT=3
|
||||
UID:recurrence-override@test
|
||||
SUMMARY:Weekly Standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260310T140000
|
||||
DTEND;TZID=Europe/Berlin:20260310T150000
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20260309T100000
|
||||
UID:recurrence-override@test
|
||||
SUMMARY:Moved Standup
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(
|
||||
data["recurrence-override@test"],
|
||||
moment("2026-03-01"),
|
||||
moment("2026-03-31")
|
||||
);
|
||||
|
||||
expect(instances).toHaveLength(3);
|
||||
|
||||
// First occurrence: Monday March 2, 10:00 (unchanged)
|
||||
expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-02 10:00");
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent(instances[0].event)).toBe("Weekly Standup");
|
||||
|
||||
// Second occurrence: moved to Tuesday March 10, 14:00
|
||||
expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-10 14:00");
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent(instances[1].event)).toBe("Moved Standup");
|
||||
|
||||
// Third occurrence: Monday March 16, 10:00 (unchanged)
|
||||
expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-16 10:00");
|
||||
});
|
||||
|
||||
it("should handle events with DURATION instead of DTEND", () => {
|
||||
// RFC 5545 allows DURATION as alternative to DTEND
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260315T090000Z
|
||||
DURATION:PT1H30M
|
||||
UID:duration-test@test
|
||||
SUMMARY:Duration Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(
|
||||
data["duration-test@test"],
|
||||
moment("2026-03-14"),
|
||||
moment("2026-03-16")
|
||||
);
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
// End should be 90 minutes after start
|
||||
const durationMinutes = instances[0].endMoment.diff(instances[0].startMoment, "minutes");
|
||||
expect(durationMinutes).toBe(90);
|
||||
});
|
||||
|
||||
it("should handle recurring events with DURATION instead of DTEND", () => {
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260301T080000
|
||||
DURATION:PT45M
|
||||
RRULE:FREQ=DAILY;COUNT=3
|
||||
UID:recurring-duration@test
|
||||
SUMMARY:Daily Scrum
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(
|
||||
data["recurring-duration@test"],
|
||||
moment("2026-02-28"),
|
||||
moment("2026-03-05")
|
||||
);
|
||||
|
||||
expect(instances).toHaveLength(3);
|
||||
for (const inst of instances) {
|
||||
const durationMinutes = inst.endMoment.diff(inst.startMoment, "minutes");
|
||||
expect(durationMinutes).toBe(45);
|
||||
}
|
||||
expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-01");
|
||||
expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-02");
|
||||
expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-03");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterEvents error handling", () => {
|
||||
it("should skip a broken event but still return other valid events", () => {
|
||||
const start = moment().add(1, "hours").toDate();
|
||||
const end = moment().add(2, "hours").toDate();
|
||||
|
||||
vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => {
|
||||
throw new TypeError("invalid rrule");
|
||||
});
|
||||
|
||||
const result = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
brokenEvent: { type: "VEVENT", start, end, summary: "Broken" },
|
||||
goodEvent: { type: "VEVENT", start, end, summary: "Good" }
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe("Good");
|
||||
});
|
||||
|
||||
it("should let expandRecurringEvent throw through directly", () => {
|
||||
vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => {
|
||||
throw new TypeError("invalid rrule");
|
||||
});
|
||||
|
||||
const event = { type: "VEVENT", start: new Date(), end: new Date(), summary: "Broken Event" };
|
||||
expect(() => CalendarFetcherUtils.expandRecurringEvent(event, moment(), moment().add(1, "days"))).toThrow("invalid rrule");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapParameterValue", () => {
|
||||
it("should return the val of a ParameterValue object", () => {
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue({ val: "Text", params: { LANGUAGE: "de" } })).toBe("Text");
|
||||
});
|
||||
|
||||
it("should return a plain string unchanged", () => {
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue("plain")).toBe("plain");
|
||||
});
|
||||
|
||||
it("should return falsy values unchanged", () => {
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue(undefined)).toBeUndefined();
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTitleFromEvent", () => {
|
||||
it("should return summary string directly", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ summary: "My Event" })).toBe("My Event");
|
||||
});
|
||||
|
||||
it("should unwrap ParameterValue summary", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ summary: { val: "My Event", params: {} } })).toBe("My Event");
|
||||
});
|
||||
|
||||
it("should fall back to description string", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ description: "Desc" })).toBe("Desc");
|
||||
});
|
||||
|
||||
it("should unwrap ParameterValue description as fallback title", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ description: { val: "Desc", params: { LANGUAGE: "de" } } })).toBe("Desc");
|
||||
});
|
||||
|
||||
it("should return 'Event' when neither summary nor description is present", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({})).toBe("Event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterEvents with ParameterValue properties", () => {
|
||||
it("should handle DESCRIPTION;LANGUAGE=de and LOCATION;LANGUAGE=de without [object Object]", () => {
|
||||
const start = moment().add(1, "hours").toDate();
|
||||
const end = moment().add(2, "hours").toDate();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
event1: {
|
||||
type: "VEVENT",
|
||||
start,
|
||||
end,
|
||||
summary: "Test",
|
||||
description: { val: "Beschreibung", params: { LANGUAGE: "de" } },
|
||||
location: { val: "Berlin", params: { LANGUAGE: "de" } }
|
||||
}
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(1);
|
||||
expect(filteredEvents[0].description).toBe("Beschreibung");
|
||||
expect(filteredEvents[0].location).toBe("Berlin");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
global.moment = require("moment");
|
||||
|
||||
const CalendarUtils = require("../../../../../modules/default/calendar/calendarutils");
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const CalendarUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarutils`);
|
||||
|
||||
describe("Calendar utils tests", () => {
|
||||
describe("capFirst", () => {
|
||||
|
||||
@@ -16,12 +16,13 @@ describe("Compliments module", () => {
|
||||
global.Cron = vi.fn();
|
||||
|
||||
// Load the module
|
||||
require("../../../../../modules/default/compliments/compliments");
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
require(`../../../../../${defaults.defaultModulesDir}/compliments/compliments`);
|
||||
|
||||
// Setup module instance
|
||||
complimentsModule.config = { ...complimentsModule.defaults };
|
||||
complimentsModule.name = "compliments";
|
||||
complimentsModule.file = vi.fn((path) => `http://localhost:8080/modules/default/compliments/${path}`);
|
||||
complimentsModule.file = vi.fn((path) => `http://localhost:8080/${defaults.defaultModulesDir}/compliments/${path}`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -144,7 +145,7 @@ describe("Compliments module", () => {
|
||||
await complimentsModule.loadComplimentFile();
|
||||
|
||||
const calledUrl = fetch.mock.calls[0][0];
|
||||
expect(calledUrl).toBe("http://localhost:8080/modules/default/compliments/compliments.json");
|
||||
expect(calledUrl).toBe("http://localhost:8080/defaultmodules/compliments/compliments.json");
|
||||
expect(calledUrl).not.toContain("dummy=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,113 +1,13 @@
|
||||
global.moment = require("moment-timezone");
|
||||
const { performWebRequest, formatTime } = require("../../../../modules/default/utils");
|
||||
const defaults = require("../../../../js/defaults");
|
||||
|
||||
const { formatTime } = require(`../../../../${defaults.defaultModulesDir}/utils`);
|
||||
|
||||
describe("Default modules utils tests", () => {
|
||||
describe("performWebRequest", () => {
|
||||
const locationHost = "localhost:8080";
|
||||
const locationProtocol = "http";
|
||||
|
||||
let fetchResponse;
|
||||
let fetchMock;
|
||||
let urlToCall;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchResponse = new Response();
|
||||
global.fetch = vi.fn(() => Promise.resolve(fetchResponse));
|
||||
fetchMock = global.fetch;
|
||||
});
|
||||
|
||||
describe("When using cors proxy", () => {
|
||||
Object.defineProperty(global, "location", {
|
||||
value: {
|
||||
host: locationHost,
|
||||
protocol: locationProtocol
|
||||
}
|
||||
});
|
||||
|
||||
it("Calls correct URL once", async () => {
|
||||
urlToCall = "http://www.test.com/path?param1=value1";
|
||||
|
||||
await performWebRequest(urlToCall, "json", true);
|
||||
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?url=${urlToCall}`);
|
||||
});
|
||||
|
||||
it("Sends correct headers", async () => {
|
||||
urlToCall = "http://www.test.com/path?param1=value1";
|
||||
|
||||
const headers = [
|
||||
{ name: "header1", value: "value1" },
|
||||
{ name: "header2", value: "value2" }
|
||||
];
|
||||
|
||||
await performWebRequest(urlToCall, "json", true, headers);
|
||||
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?sendheaders=header1:value1,header2:value2&url=${urlToCall}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When not using cors proxy", () => {
|
||||
it("Calls correct URL once", async () => {
|
||||
urlToCall = "http://www.test.com/path?param1=value1";
|
||||
|
||||
await performWebRequest(urlToCall);
|
||||
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(urlToCall);
|
||||
});
|
||||
|
||||
it("Sends correct headers", async () => {
|
||||
urlToCall = "http://www.test.com/path?param1=value1";
|
||||
const headers = [
|
||||
{ name: "header1", value: "value1" },
|
||||
{ name: "header2", value: "value2" }
|
||||
];
|
||||
|
||||
await performWebRequest(urlToCall, "json", false, headers);
|
||||
|
||||
const expectedHeaders = { headers: { header1: "value1", header2: "value2" } };
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][1]).toStrictEqual(expectedHeaders);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When receiving json format", () => {
|
||||
it("Returns undefined when no data is received", async () => {
|
||||
urlToCall = "www.test.com";
|
||||
|
||||
const response = await performWebRequest(urlToCall);
|
||||
|
||||
expect(response).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Returns object when data is received", async () => {
|
||||
urlToCall = "www.test.com";
|
||||
fetchResponse = new Response("{\"body\": \"some content\"}");
|
||||
|
||||
const response = await performWebRequest(urlToCall);
|
||||
|
||||
expect(response.body).toBe("some content");
|
||||
});
|
||||
|
||||
it("Returns expected headers when data is received", async () => {
|
||||
urlToCall = "www.test.com";
|
||||
fetchResponse = new Response("{\"body\": \"some content\"}", { headers: { header1: "value1", header2: "value2" } });
|
||||
|
||||
const response = await performWebRequest(urlToCall, "json", false, undefined, ["header1"]);
|
||||
|
||||
expect(response.headers).toHaveLength(1);
|
||||
expect(response.headers[0].name).toBe("header1");
|
||||
expect(response.headers[0].value).toBe("value1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
const time = new Date();
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
time.setHours(13, 13);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const providerUtils = require(`../../../../../${defaults.defaultModulesDir}/weather/provider-utils`);
|
||||
|
||||
describe("Weather provider utils tests", () => {
|
||||
describe("convertWeatherType", () => {
|
||||
it("should convert OpenWeatherMap day icons correctly", () => {
|
||||
expect(providerUtils.convertWeatherType("01d")).toBe("day-sunny");
|
||||
expect(providerUtils.convertWeatherType("02d")).toBe("day-cloudy");
|
||||
expect(providerUtils.convertWeatherType("10d")).toBe("rain");
|
||||
expect(providerUtils.convertWeatherType("13d")).toBe("snow");
|
||||
});
|
||||
|
||||
it("should convert OpenWeatherMap night icons correctly", () => {
|
||||
expect(providerUtils.convertWeatherType("01n")).toBe("night-clear");
|
||||
expect(providerUtils.convertWeatherType("02n")).toBe("night-cloudy");
|
||||
expect(providerUtils.convertWeatherType("10n")).toBe("night-rain");
|
||||
});
|
||||
|
||||
it("should return null for unknown weather types", () => {
|
||||
expect(providerUtils.convertWeatherType("99x")).toBeNull();
|
||||
expect(providerUtils.convertWeatherType("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTimezoneOffset", () => {
|
||||
it("should apply positive offset correctly", () => {
|
||||
const date = new Date("2026-02-02T12:00:00Z");
|
||||
const result = providerUtils.applyTimezoneOffset(date, 120); // +2 hours
|
||||
// The function converts to UTC, then applies offset
|
||||
const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 + 120 * 60000);
|
||||
expect(result.getTime()).toBe(expected.getTime());
|
||||
});
|
||||
|
||||
it("should apply negative offset correctly", () => {
|
||||
const date = new Date("2026-02-02T12:00:00Z");
|
||||
const result = providerUtils.applyTimezoneOffset(date, -300); // -5 hours
|
||||
const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 - 300 * 60000);
|
||||
expect(result.getTime()).toBe(expected.getTime());
|
||||
});
|
||||
|
||||
it("should handle zero offset", () => {
|
||||
const date = new Date("2026-02-02T12:00:00Z");
|
||||
const result = providerUtils.applyTimezoneOffset(date, 0);
|
||||
const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000);
|
||||
expect(result.getTime()).toBe(expected.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("limitDecimals", () => {
|
||||
it("should truncate decimals correctly", () => {
|
||||
expect(providerUtils.limitDecimals(12.3456789, 4)).toBe(12.3456);
|
||||
expect(providerUtils.limitDecimals(12.3456789, 2)).toBe(12.34);
|
||||
});
|
||||
|
||||
it("should handle values with fewer decimals than limit", () => {
|
||||
expect(providerUtils.limitDecimals(12.34, 6)).toBe(12.34);
|
||||
expect(providerUtils.limitDecimals(12, 4)).toBe(12);
|
||||
});
|
||||
|
||||
it("should handle negative values", () => {
|
||||
expect(providerUtils.limitDecimals(-12.3456789, 2)).toBe(-12.34);
|
||||
});
|
||||
|
||||
it("should truncate not round", () => {
|
||||
expect(providerUtils.limitDecimals(12.9999, 2)).toBe(12.99);
|
||||
expect(providerUtils.limitDecimals(12.9999, 0)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSunTimes", () => {
|
||||
it("should return sunrise and sunset times", () => {
|
||||
const date = new Date("2026-06-21T12:00:00Z"); // Summer solstice
|
||||
const lat = 52.52; // Berlin
|
||||
const lon = 13.405;
|
||||
|
||||
const result = providerUtils.getSunTimes(date, lat, lon);
|
||||
|
||||
expect(result).toHaveProperty("sunrise");
|
||||
expect(result).toHaveProperty("sunset");
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
expect(result.sunrise.getTime()).toBeLessThan(result.sunset.getTime());
|
||||
});
|
||||
|
||||
it("should handle different locations", () => {
|
||||
const date = new Date("2026-06-21T12:00:00Z");
|
||||
|
||||
// London
|
||||
const london = providerUtils.getSunTimes(date, 51.5074, -0.1278);
|
||||
// Tokyo
|
||||
const tokyo = providerUtils.getSunTimes(date, 35.6762, 139.6503);
|
||||
|
||||
expect(london.sunrise.getTime()).not.toBe(tokyo.sunrise.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDayTime", () => {
|
||||
it("should return true when time is between sunrise and sunset", () => {
|
||||
const sunrise = new Date("2026-02-02T07:00:00Z");
|
||||
const sunset = new Date("2026-02-02T17:00:00Z");
|
||||
const noon = new Date("2026-02-02T12:00:00Z");
|
||||
|
||||
expect(providerUtils.isDayTime(noon, sunrise, sunset)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when time is before sunrise", () => {
|
||||
const sunrise = new Date("2026-02-02T07:00:00Z");
|
||||
const sunset = new Date("2026-02-02T17:00:00Z");
|
||||
const night = new Date("2026-02-02T03:00:00Z");
|
||||
|
||||
expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when time is after sunset", () => {
|
||||
const sunrise = new Date("2026-02-02T07:00:00Z");
|
||||
const sunset = new Date("2026-02-02T17:00:00Z");
|
||||
const night = new Date("2026-02-02T20:00:00Z");
|
||||
|
||||
expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if sunrise/sunset are null", () => {
|
||||
const noon = new Date("2026-02-02T12:00:00Z");
|
||||
expect(providerUtils.isDayTime(noon, null, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimezoneOffset", () => {
|
||||
it("should format positive offsets correctly", () => {
|
||||
expect(providerUtils.formatTimezoneOffset(60)).toBe("+01:00");
|
||||
expect(providerUtils.formatTimezoneOffset(120)).toBe("+02:00");
|
||||
expect(providerUtils.formatTimezoneOffset(330)).toBe("+05:30"); // India
|
||||
});
|
||||
|
||||
it("should format negative offsets correctly", () => {
|
||||
expect(providerUtils.formatTimezoneOffset(-300)).toBe("-05:00"); // EST
|
||||
expect(providerUtils.formatTimezoneOffset(-480)).toBe("-08:00"); // PST
|
||||
});
|
||||
|
||||
it("should format zero offset correctly", () => {
|
||||
expect(providerUtils.formatTimezoneOffset(0)).toBe("+00:00");
|
||||
});
|
||||
|
||||
it("should pad single digits with zero", () => {
|
||||
expect(providerUtils.formatTimezoneOffset(5)).toBe("+00:05");
|
||||
expect(providerUtils.formatTimezoneOffset(-5)).toBe("-00:05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDateString", () => {
|
||||
it("should format date as YYYY-MM-DD (local time)", () => {
|
||||
const date = new Date(2026, 1, 2, 12, 34, 56); // Feb 2, 2026 (month is 0-indexed)
|
||||
expect(providerUtils.getDateString(date)).toBe("2026-02-02");
|
||||
});
|
||||
|
||||
it("should handle single-digit months and days correctly", () => {
|
||||
const date = new Date(2026, 0, 5, 12, 0, 0); // Jan 5, 2026
|
||||
expect(providerUtils.getDateString(date)).toBe("2026-01-05");
|
||||
});
|
||||
|
||||
it("should handle end of year", () => {
|
||||
const date = new Date(2025, 11, 31, 23, 59, 59); // Dec 31, 2025
|
||||
expect(providerUtils.getDateString(date)).toBe("2025-12-31");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Environment Canada Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
* Environment Canada is the Canadian weather service (XML-based).
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const indexHTML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada_index.html"), "utf-8");
|
||||
const cityPageXML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada.xml"), "utf-8");
|
||||
|
||||
// Match directory listing (index) - must end with / and nothing after
|
||||
const ENVCANADA_INDEX_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/$/;
|
||||
// Match actual XML files
|
||||
const ENVCANADA_CITYPAGE_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/.*\.xml$/;
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer(
|
||||
http.get(ENVCANADA_INDEX_PATTERN, () => {
|
||||
return new HttpResponse(indexHTML, {
|
||||
headers: { "Content-Type": "text/html" }
|
||||
});
|
||||
}),
|
||||
http.get(ENVCANADA_CITYPAGE_PATTERN, () => {
|
||||
return new HttpResponse(cityPageXML, {
|
||||
headers: { "Content-Type": "application/xml" }
|
||||
});
|
||||
})
|
||||
);
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("EnvCanadaProvider", () => {
|
||||
let EnvCanadaProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/envcanada");
|
||||
EnvCanadaProvider = module.default || module;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.siteCode).toBe("s0000458");
|
||||
expect(provider.config.provCode).toBe("ON");
|
||||
expect(provider.config.type).toBe("current");
|
||||
});
|
||||
|
||||
it("should throw error if siteCode or provCode missing", () => {
|
||||
const provider = new EnvCanadaProvider({ siteCode: "", provCode: "" });
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
expect(() => provider.initialize()).toThrow("siteCode and provCode are required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Two-Step Fetch Pattern", () => {
|
||||
it("should first fetch index page then city page", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather from XML", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve, reject) => {
|
||||
provider.setCallbacks(
|
||||
(data) => {
|
||||
resolve(data);
|
||||
},
|
||||
(error) => {
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBe(-20.3);
|
||||
expect(result.windSpeed).toBeCloseTo(5.28, 1); // 19 km/h -> m/s
|
||||
expect(result.windFromDirection).toBe(346); // NNW
|
||||
expect(result.humidity).toBe(67);
|
||||
});
|
||||
|
||||
it("should use wind chill for feels like temperature when available", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// XML has windChill of -31
|
||||
expect(result.feelsLikeTemp).toBe(-31);
|
||||
});
|
||||
|
||||
it("should parse sunrise/sunset from XML", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should convert icon code to weather type", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Icon code 40 = "Blowing Snow" → "snow-wind"
|
||||
expect(result.weatherType).toBe("snow-wind");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast from XML", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const day = result[0];
|
||||
expect(day).toHaveProperty("date");
|
||||
expect(day).toHaveProperty("minTemperature");
|
||||
expect(day).toHaveProperty("maxTemperature");
|
||||
expect(day).toHaveProperty("precipitationProbability");
|
||||
expect(day).toHaveProperty("weatherType");
|
||||
});
|
||||
|
||||
it("should extract max precipitation probability from day/night", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Real data has 40% for both day and night periods
|
||||
expect(result[0].precipitationProbability).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast from XML", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(24); // Real data has 24 hourly forecasts
|
||||
const hour = result[0];
|
||||
expect(hour).toHaveProperty("date");
|
||||
expect(hour).toHaveProperty("temperature");
|
||||
expect(hour).toHaveProperty("precipitationProbability");
|
||||
expect(hour).toHaveProperty("weatherType");
|
||||
});
|
||||
|
||||
it("should parse EC time format correctly", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s0000458",
|
||||
provCode: "ON",
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// First hourly forecast is for 202602071300 = 2026-02-07 13:00 UTC
|
||||
const expectedDate = new Date(Date.UTC(2026, 1, 7, 13, 0, 0));
|
||||
expect(result[0].date.getTime()).toBe(expectedDate.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle missing city page URL", async () => {
|
||||
const provider = new EnvCanadaProvider({
|
||||
siteCode: "s9999999", // Invalid site code
|
||||
provCode: "ON",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
let errorCalled = false;
|
||||
provider.setCallbacks(vi.fn(), () => {
|
||||
errorCalled = true;
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
// Should not call error callback if URL not found (it's expected during hour transitions)
|
||||
// Wait a bit to see if callback is called
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(errorCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* OpenMeteo Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
* Uses MSW to mock HTTP responses from the Open-Meteo API.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import openMeteoData from "../../../../../mocks/weather_openmeteo_current.json" with { type: "json" };
|
||||
import openMeteoCurrentWeatherData from "../../../../../mocks/weather_openmeteo_current_weather.json" with { type: "json" };
|
||||
// Real API returns current + forecast in one response
|
||||
const currentData = openMeteoCurrentWeatherData;
|
||||
const forecastData = openMeteoData;
|
||||
|
||||
const GEOCODE_URL = "https://api.bigdatacloud.net/data/reverse-geocode-client*";
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock global fetch for geocoding (used by provider's #fetchLocation)
|
||||
server = setupServer(
|
||||
http.get(GEOCODE_URL, () => {
|
||||
return HttpResponse.json({
|
||||
city: "Munich",
|
||||
locality: "Munich",
|
||||
principalSubdivisionCode: "BY"
|
||||
});
|
||||
})
|
||||
);
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("OpenMeteoProvider", () => {
|
||||
let OpenMeteoProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/openmeteo");
|
||||
OpenMeteoProvider = module.default;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.lat).toBe(48.14);
|
||||
expect(provider.config.lon).toBe(11.58);
|
||||
expect(provider.config.type).toBe("current");
|
||||
});
|
||||
|
||||
it("should have default values", () => {
|
||||
const provider = new OpenMeteoProvider({});
|
||||
expect(provider.config.lat).toBe(0);
|
||||
expect(provider.config.lon).toBe(0);
|
||||
expect(provider.config.type).toBe("current");
|
||||
expect(provider.config.maxNumberOfDays).toBe(5);
|
||||
expect(provider.config.apiBase).toBe("https://api.open-meteo.com/v1");
|
||||
});
|
||||
|
||||
it("should initialize without callbacks", async () => {
|
||||
const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 });
|
||||
await expect(provider.initialize()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should resolve location name via geocoding", async () => {
|
||||
const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 });
|
||||
await provider.initialize();
|
||||
expect(provider.locationName).toBe("Munich, BY");
|
||||
});
|
||||
|
||||
it("should use forecast_days instead of static start_date/end_date", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
const url = new URL(provider.fetcher.url);
|
||||
const params = url.searchParams;
|
||||
|
||||
expect(params.get("forecast_days")).toBe("1");
|
||||
expect(params.has("start_date")).toBe(false);
|
||||
expect(params.has("end_date")).toBe(false);
|
||||
});
|
||||
|
||||
it("should set forecast_days based on maxNumberOfDays for forecast type", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "forecast",
|
||||
maxNumberOfDays: 5
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
const url = new URL(provider.fetcher.url);
|
||||
const params = url.searchParams;
|
||||
|
||||
expect(params.get("forecast_days")).toBe("6"); // 5 days + 1
|
||||
expect(params.has("start_date")).toBe(false);
|
||||
expect(params.has("end_date")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather data correctly", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve, reject) => {
|
||||
provider.setCallbacks(
|
||||
(data) => {
|
||||
resolve(data);
|
||||
},
|
||||
(error) => {
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.open-meteo.com/v1/forecast*", () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBe(8.5);
|
||||
expect(result.windSpeed).toBeCloseTo(4.7, 1);
|
||||
expect(result.windFromDirection).toBe(9);
|
||||
expect(result.humidity).toBe(83);
|
||||
});
|
||||
|
||||
it("should include sunrise and sunset from daily data", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve, reject) => {
|
||||
provider.setCallbacks(
|
||||
(data) => {
|
||||
resolve(data);
|
||||
},
|
||||
(error) => {
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.open-meteo.com/v1/forecast*", () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
expect(result.minTemperature).toBe(4.7);
|
||||
expect(result.maxTemperature).toBe(9.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data correctly", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.open-meteo.com/v1/forecast*", () => {
|
||||
return HttpResponse.json(forecastData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(7);
|
||||
const firstDay = result[0];
|
||||
expect(firstDay.minTemperature).toBe(-9.2);
|
||||
expect(firstDay.maxTemperature).toBe(-0.2);
|
||||
expect(firstDay.temperature).toBeCloseTo(-4.7, 0); // (-0.2+-9.2)/2
|
||||
|
||||
expect(firstDay.sunrise).toBeInstanceOf(Date);
|
||||
expect(firstDay.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should include precipitation data in forecast", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.open-meteo.com/v1/forecast*", () => {
|
||||
return HttpResponse.json(forecastData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Mock data has no rain_sum field - provider returns null for missing data
|
||||
expect(result[0].rain).toBeNull();
|
||||
// precipitation_sum has value 0.0 in mock data
|
||||
expect(result[0].precipitationAmount).toBe(0.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should call error callback on invalid API response", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.open-meteo.com/v1/forecast*", () => {
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("message");
|
||||
expect(error).toHaveProperty("translationKey");
|
||||
});
|
||||
|
||||
it("should call error callback on network failure", async () => {
|
||||
const provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.open-meteo.com/v1/forecast*", () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Callback Interface", () => {
|
||||
it("should store callbacks via setCallbacks", () => {
|
||||
const provider = new OpenMeteoProvider({});
|
||||
const onData = vi.fn();
|
||||
const onError = vi.fn();
|
||||
provider.setCallbacks(onData, onError);
|
||||
expect(provider.onDataCallback).toBe(onData);
|
||||
expect(provider.onErrorCallback).toBe(onError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lifecycle", () => {
|
||||
it("should have start/stop methods", () => {
|
||||
const provider = new OpenMeteoProvider({});
|
||||
expect(typeof provider.start).toBe("function");
|
||||
expect(typeof provider.stop).toBe("function");
|
||||
});
|
||||
|
||||
it("should clear timer on stop", async () => {
|
||||
const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 });
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
|
||||
server.use(
|
||||
http.get("https://api.open-meteo.com/v1/forecast*", () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.stop();
|
||||
|
||||
// Should not throw
|
||||
expect(provider.fetcher).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* OpenWeatherMap Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import onecallData from "../../../../../mocks/weather_owm_onecall.json" with { type: "json" };
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("OpenWeatherMapProvider", () => {
|
||||
let OpenWeatherMapProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/openweathermap");
|
||||
OpenWeatherMapProvider = module.default;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: "test-key"
|
||||
});
|
||||
expect(provider.config.lat).toBe(48.14);
|
||||
expect(provider.config.lon).toBe(11.58);
|
||||
expect(provider.config.apiKey).toBe("test-key");
|
||||
});
|
||||
|
||||
it("should have default values", () => {
|
||||
const provider = new OpenWeatherMapProvider({ apiKey: "test" });
|
||||
expect(provider.config.apiVersion).toBe("3.0");
|
||||
expect(provider.config.weatherEndpoint).toBe("/onecall");
|
||||
expect(provider.config.apiBase).toBe("https://api.openweathermap.org/data/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Key Validation", () => {
|
||||
it("should call error callback without API key", async () => {
|
||||
const provider = new OpenWeatherMapProvider({ apiKey: "" });
|
||||
const onError = vi.fn();
|
||||
provider.setCallbacks(vi.fn(), onError);
|
||||
await provider.initialize();
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "API key is required" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should not create fetcher without API key", async () => {
|
||||
const provider = new OpenWeatherMapProvider({ apiKey: "" });
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
await provider.initialize();
|
||||
expect(provider.fetcher).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw if setCallbacks not called before initialize", () => {
|
||||
const provider = new OpenWeatherMapProvider({ apiKey: "test" });
|
||||
expect(() => provider.initialize()).toThrow("setCallbacks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse onecall current weather data", async () => {
|
||||
const provider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: "test-key",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.openweathermap.org/data/3.0/onecall", () => {
|
||||
return HttpResponse.json(onecallData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.temperature).toBe(-0.27);
|
||||
expect(result.windSpeed).toBe(3.09);
|
||||
expect(result.windFromDirection).toBe(220);
|
||||
expect(result.humidity).toBe(54);
|
||||
expect(result.uvIndex).toBe(0);
|
||||
expect(result.feelsLikeTemp).toBe(-3.9);
|
||||
expect(result.weatherType).toBe("cloudy-windy");
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should include precipitation data in current weather", async () => {
|
||||
const provider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: "test-key",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.openweathermap.org/data/3.0/onecall", () => {
|
||||
return HttpResponse.json(onecallData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Real data has no precipitation
|
||||
expect(result.rain).toBeUndefined();
|
||||
expect(result.snow).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: "test-key",
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.openweathermap.org/data/3.0/onecall", () => {
|
||||
return HttpResponse.json(onecallData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(8);
|
||||
expect(result[0].minTemperature).toBe(-11.86);
|
||||
expect(result[0].maxTemperature).toBe(-0.27);
|
||||
expect(result[0].snow).toBe(0.69);
|
||||
expect(result[0].precipitationProbability).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast data", async () => {
|
||||
const provider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: "test-key",
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.openweathermap.org/data/3.0/onecall", () => {
|
||||
return HttpResponse.json(onecallData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(48);
|
||||
expect(result[0].temperature).toBe(-0.66);
|
||||
expect(result[0].precipitationProbability).toBe(0);
|
||||
expect(result[0].rain).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timezone Handling", () => {
|
||||
it("should set location name from timezone", async () => {
|
||||
const provider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: "test-key",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://api.openweathermap.org/data/3.0/onecall", () => {
|
||||
return HttpResponse.json(onecallData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
await dataPromise;
|
||||
|
||||
expect(provider.locationName).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Pirate Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
* Pirate Weather is a Dark Sky API compatible service.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import pirateweatherData from "../../../../../mocks/weather_pirateweather.json" with { type: "json" };
|
||||
|
||||
const PIRATEWEATHER_URL = "https://api.pirateweather.net/forecast/*";
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("PirateweatherProvider", () => {
|
||||
let PirateweatherProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/pirateweather");
|
||||
PirateweatherProvider = module.default || module;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-api-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.apiKey).toBe("test-api-key");
|
||||
expect(provider.config.lat).toBe(40.71);
|
||||
expect(provider.config.lon).toBe(-74.0);
|
||||
});
|
||||
|
||||
it("should error if API key is missing", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error.message).toContain("API key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather data", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBe(-0.26);
|
||||
expect(result.feelsLikeTemp).toBe(-4.77);
|
||||
expect(result.windSpeed).toBe(2.32);
|
||||
expect(result.windFromDirection).toBe(166);
|
||||
expect(Math.round(result.humidity)).toBe(56); // 0.56 * 100 with rounding
|
||||
});
|
||||
|
||||
it("should include sunrise/sunset from daily data", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should convert icon to weather type", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// "cloudy" icon from real data
|
||||
expect(result.weatherType).toBe("cloudy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(8);
|
||||
const day = result[0];
|
||||
expect(day).toHaveProperty("date");
|
||||
expect(day).toHaveProperty("minTemperature");
|
||||
expect(day).toHaveProperty("maxTemperature");
|
||||
expect(day).toHaveProperty("weatherType");
|
||||
expect(day).toHaveProperty("precipitationProbability");
|
||||
});
|
||||
|
||||
it("should convert precipitation accumulation from cm to mm", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// First day has precipAccumulation: 0.0 cm
|
||||
expect(result[0].precipitationAmount).toBe(0);
|
||||
});
|
||||
|
||||
it("should categorize precipitation by type", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// First day has precipType: "snow"
|
||||
expect(result[0].rain).toBe(0);
|
||||
expect(result[0].snow).toBe(0);
|
||||
|
||||
// Second day has precipType: "snow" with 0.0 accumulation
|
||||
expect(result[1].rain).toBe(0);
|
||||
expect(result[1].snow).toBe(0);
|
||||
});
|
||||
|
||||
it("should convert precipitation probability to percentage", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// 0.33 -> 33%
|
||||
expect(result[0].precipitationProbability).toBe(33);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast data", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(48);
|
||||
|
||||
const hour = result[0];
|
||||
expect(hour).toHaveProperty("date");
|
||||
expect(hour).toHaveProperty("temperature");
|
||||
expect(hour).toHaveProperty("feelsLikeTemp");
|
||||
expect(hour).toHaveProperty("windSpeed");
|
||||
expect(hour).toHaveProperty("weatherType");
|
||||
});
|
||||
|
||||
it("should handle hourly precipitation", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json(pirateweatherData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// First hour has 0.0 cm precipitation
|
||||
expect(result[0].precipitationAmount).toBe(0);
|
||||
expect(result[0].rain).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle invalid JSON response", async () => {
|
||||
const provider = new PirateweatherProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(PIRATEWEATHER_URL, () => {
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error.message).toContain("No usable data");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* SMHI Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
* SMHI provides data only for Sweden, uses metric system.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import smhiData from "../../../../../mocks/weather_smhi.json" with { type: "json" };
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("SMHIProvider", () => {
|
||||
let SMHIProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/smhi");
|
||||
SMHIProvider = module.default;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new SMHIProvider({
|
||||
lat: 59.3293,
|
||||
lon: 18.0686
|
||||
});
|
||||
expect(provider.config.lat).toBe(59.3293);
|
||||
expect(provider.config.lon).toBe(18.0686);
|
||||
expect(provider.config.precipitationValue).toBe("pmedian");
|
||||
});
|
||||
|
||||
it("should fallback to pmedian for invalid precipitationValue", () => {
|
||||
const provider = new SMHIProvider({
|
||||
precipitationValue: "invalid"
|
||||
});
|
||||
expect(provider.config.precipitationValue).toBe("pmedian");
|
||||
});
|
||||
|
||||
it("should accept valid precipitationValue options", () => {
|
||||
for (const value of ["pmin", "pmean", "pmedian", "pmax"]) {
|
||||
const provider = new SMHIProvider({ precipitationValue: value });
|
||||
expect(provider.config.precipitationValue).toBe(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coordinate Validation", () => {
|
||||
it("should limit coordinates to 6 decimal places", async () => {
|
||||
const provider = new SMHIProvider({
|
||||
lat: 59.32930123456789,
|
||||
lon: 18.06860123456789
|
||||
});
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
|
||||
server.use(
|
||||
http.get("https://opendata-download-metfcst.smhi.se/*", () => {
|
||||
return HttpResponse.json(smhiData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
// After validateCoordinates(config, 6), decimals should be truncated
|
||||
expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(6);
|
||||
expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather from timeSeries", async () => {
|
||||
const provider = new SMHIProvider({
|
||||
lat: 59.3293,
|
||||
lon: 18.0686,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://opendata-download-metfcst.smhi.se/*", () => {
|
||||
return HttpResponse.json(smhiData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.temperature).toBe("number");
|
||||
expect(typeof result.windSpeed).toBe("number");
|
||||
expect(typeof result.humidity).toBe("number");
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should detect precipitation category correctly", async () => {
|
||||
const provider = new SMHIProvider({
|
||||
lat: 59.3293,
|
||||
lon: 18.0686,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
// Use data with rain (pcat=3 at index 2)
|
||||
const rainData = JSON.parse(JSON.stringify(smhiData));
|
||||
// Make the rain entry the closest to "now"
|
||||
rainData.timeSeries = [rainData.timeSeries[2]];
|
||||
rainData.timeSeries[0].validTime = new Date().toISOString();
|
||||
|
||||
server.use(
|
||||
http.get("https://opendata-download-metfcst.smhi.se/*", () => {
|
||||
return HttpResponse.json(rainData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.rain).toBe(0.0); // pmedian value with pcat=3 (rain)
|
||||
expect(result.precipitationAmount).toBe(0.0);
|
||||
expect(result.snow).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new SMHIProvider({
|
||||
lat: 59.3293,
|
||||
lon: 18.0686,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://opendata-download-metfcst.smhi.se/*", () => {
|
||||
return HttpResponse.json(smhiData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const firstDay = result[0];
|
||||
expect(firstDay).toHaveProperty("date");
|
||||
expect(firstDay).toHaveProperty("minTemperature");
|
||||
expect(firstDay).toHaveProperty("maxTemperature");
|
||||
expect(firstDay.minTemperature).toBeLessThanOrEqual(firstDay.maxTemperature);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should call error callback on invalid data", async () => {
|
||||
const provider = new SMHIProvider({
|
||||
lat: 59.3293,
|
||||
lon: 18.0686,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get("https://opendata-download-metfcst.smhi.se/*", () => {
|
||||
return HttpResponse.json({ invalid: true });
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* UK Met Office DataHub Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import hourlyData from "../../../../../mocks/weather_ukmetoffice.json" with { type: "json" };
|
||||
import dailyData from "../../../../../mocks/weather_ukmetoffice_daily.json" with { type: "json" };
|
||||
|
||||
const UKMETOFFICE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly*";
|
||||
const UKMETOFFICE_THREE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/three-hourly*";
|
||||
const UKMETOFFICE_DAILY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily*";
|
||||
|
||||
/**
|
||||
* Returns a copy of the daily mock response with dates shifted to today and future days.
|
||||
* @param {object} source Source UK Met Office daily mock payload.
|
||||
* @returns {object} Cloned payload with deterministic future dates in `timeSeries`.
|
||||
*/
|
||||
function withFutureDailyTimes (source) {
|
||||
const clone = JSON.parse(JSON.stringify(source));
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
clone.features[0].properties.timeSeries = clone.features[0].properties.timeSeries.map((day, index) => {
|
||||
const shiftedDate = new Date(today);
|
||||
shiftedDate.setUTCDate(today.getUTCDate() + index);
|
||||
|
||||
return {
|
||||
...day,
|
||||
time: `${shiftedDate.toISOString().split("T")[0]}T00:00Z`
|
||||
};
|
||||
});
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("UKMetOfficeDataHubProvider", () => {
|
||||
let UKMetOfficeDataHubProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/ukmetofficedatahub");
|
||||
UKMetOfficeDataHubProvider = module.default || module;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-api-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.apiKey).toBe("test-api-key");
|
||||
expect(provider.config.lat).toBe(51.5);
|
||||
expect(provider.config.lon).toBe(-0.12);
|
||||
});
|
||||
|
||||
it("should error if API key is missing", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
lat: 51.5,
|
||||
lon: -0.12
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error.message).toContain("API key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Type Mapping", () => {
|
||||
it("should use hourly endpoint for current type", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
let requestedUrl = null;
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_HOURLY_URL, ({ request }) => {
|
||||
requestedUrl = request.url;
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(requestedUrl).toContain("/hourly?");
|
||||
});
|
||||
|
||||
it("should use daily endpoint for forecast type", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
let requestedUrl = null;
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_DAILY_URL, ({ request }) => {
|
||||
requestedUrl = request.url;
|
||||
return HttpResponse.json(dailyData);
|
||||
})
|
||||
);
|
||||
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(requestedUrl).toContain("/daily?");
|
||||
});
|
||||
|
||||
it("should use three-hourly endpoint for hourly type", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
let requestedUrl = null;
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_THREE_HOURLY_URL, ({ request }) => {
|
||||
requestedUrl = request.url;
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(requestedUrl).toContain("/three-hourly?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather from hourly data", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBeDefined();
|
||||
expect(result.windSpeed).toBeDefined();
|
||||
expect(result.humidity).toBeDefined();
|
||||
expect(result.weatherType).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should include sunrise/sunset from SunCalc", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should convert weather code to weather type", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.weatherType).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_DAILY_URL, () => {
|
||||
return HttpResponse.json(withFutureDailyTimes(dailyData));
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const day = result[0];
|
||||
expect(day).toHaveProperty("date");
|
||||
expect(day).toHaveProperty("minTemperature");
|
||||
expect(day).toHaveProperty("maxTemperature");
|
||||
expect(day).toHaveProperty("weatherType");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast data", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_THREE_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const hour = result[0];
|
||||
expect(hour).toHaveProperty("date");
|
||||
expect(hour).toHaveProperty("temperature");
|
||||
expect(hour).toHaveProperty("windSpeed");
|
||||
expect(hour).toHaveProperty("weatherType");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle invalid response", async () => {
|
||||
const provider = new UKMetOfficeDataHubProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(UKMETOFFICE_HOURLY_URL, () => {
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* WeatherAPI Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
const WEATHER_API_URL = "https://api.weatherapi.com/v1/forecast.json*";
|
||||
|
||||
/**
|
||||
* Builds a stable WeatherAPI mock payload for current, daily, and hourly parsing tests.
|
||||
* @returns {object} WeatherAPI forecast response fixture.
|
||||
*/
|
||||
function buildWeatherApiResponse () {
|
||||
return {
|
||||
location: {
|
||||
name: "Toronto",
|
||||
region: "Ontario",
|
||||
country: "Canada"
|
||||
},
|
||||
current: {
|
||||
last_updated_epoch: 4102444800,
|
||||
temp_c: -2.5,
|
||||
feelslike_c: -7.1,
|
||||
humidity: 75,
|
||||
wind_kph: 18,
|
||||
wind_degree: 220,
|
||||
condition: { code: 1003 },
|
||||
is_day: 1,
|
||||
snow_cm: 0.2,
|
||||
precip_mm: 1.1,
|
||||
uv: 2
|
||||
},
|
||||
forecast: {
|
||||
forecastday: [
|
||||
{
|
||||
date: "2100-01-01",
|
||||
date_epoch: 4102444800,
|
||||
astro: {
|
||||
sunrise: "07:45 AM",
|
||||
sunset: "05:05 PM"
|
||||
},
|
||||
day: {
|
||||
mintemp_c: -8,
|
||||
maxtemp_c: -1,
|
||||
avgtemp_c: -4,
|
||||
avghumidity: 81,
|
||||
totalsnow_cm: 0.6,
|
||||
totalprecip_mm: 2,
|
||||
maxwind_kph: 22,
|
||||
uv: 1,
|
||||
condition: { code: 1183 }
|
||||
},
|
||||
hour: [
|
||||
{
|
||||
time: "2100-01-01 01:00",
|
||||
time_epoch: 4102448400,
|
||||
temp_c: -5,
|
||||
humidity: 85,
|
||||
wind_kph: 15,
|
||||
wind_degree: 210,
|
||||
wind_dir: "SSW",
|
||||
condition: { code: 1183 },
|
||||
is_day: 0,
|
||||
snow_cm: 0.1,
|
||||
precip_mm: 0.5,
|
||||
will_it_rain: 1,
|
||||
will_it_snow: 0,
|
||||
uv: 0
|
||||
},
|
||||
{
|
||||
time: "2100-01-01 02:00",
|
||||
time_epoch: 4102452000,
|
||||
temp_c: -4.5,
|
||||
humidity: 83,
|
||||
wind_kph: 13,
|
||||
wind_degree: 205,
|
||||
wind_dir: "SSW",
|
||||
condition: { code: 1003 },
|
||||
is_day: 0,
|
||||
snow_cm: 0,
|
||||
precip_mm: 0.2,
|
||||
will_it_rain: 0,
|
||||
will_it_snow: 0,
|
||||
uv: 0
|
||||
},
|
||||
{
|
||||
time: "2100-01-01 03:00",
|
||||
time_epoch: 4102455600,
|
||||
temp_c: -4,
|
||||
humidity: 80,
|
||||
wind_kph: 12,
|
||||
wind_degree: 200,
|
||||
wind_dir: "SSW",
|
||||
condition: { code: 1000 },
|
||||
is_day: 0,
|
||||
snow_cm: 0,
|
||||
precip_mm: 0,
|
||||
will_it_rain: 0,
|
||||
will_it_snow: 0,
|
||||
uv: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: "2100-01-02",
|
||||
date_epoch: 4102531200,
|
||||
astro: {
|
||||
sunrise: "07:44 AM",
|
||||
sunset: "05:06 PM"
|
||||
},
|
||||
day: {
|
||||
mintemp_c: -7,
|
||||
maxtemp_c: 0,
|
||||
avgtemp_c: -3.3,
|
||||
avghumidity: 78,
|
||||
totalsnow_cm: 0,
|
||||
totalprecip_mm: 0.4,
|
||||
maxwind_kph: 20,
|
||||
uv: 2,
|
||||
condition: { code: 1006 }
|
||||
},
|
||||
hour: []
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("WeatherAPIProvider", () => {
|
||||
let WeatherAPIProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/weatherapi");
|
||||
WeatherAPIProvider = module.default;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new WeatherAPIProvider({
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
apiKey: "test-key",
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.lat).toBe(43.65);
|
||||
expect(provider.config.lon).toBe(-79.38);
|
||||
expect(provider.config.apiKey).toBe("test-key");
|
||||
expect(provider.config.type).toBe("current");
|
||||
});
|
||||
|
||||
it("should have default values", () => {
|
||||
const provider = new WeatherAPIProvider({ apiKey: "test-key" });
|
||||
expect(provider.config.apiBase).toBe("https://api.weatherapi.com/v1");
|
||||
expect(provider.config.type).toBe("current");
|
||||
expect(provider.config.maxEntries).toBe(5);
|
||||
expect(provider.config.updateInterval).toBe(10 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather data correctly", async () => {
|
||||
const provider = new WeatherAPIProvider({
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
apiKey: "test-key",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve, reject) => {
|
||||
provider.setCallbacks(resolve, reject);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse()))
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBe(-2.5);
|
||||
expect(result.feelsLikeTemp).toBe(-7.1);
|
||||
expect(result.humidity).toBe(75);
|
||||
expect(result.windSpeed).toBeCloseTo(5, 1);
|
||||
expect(result.windFromDirection).toBe(220);
|
||||
expect(result.weatherType).toBe("day-cloudy");
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
expect(result.minTemperature).toBe(-8);
|
||||
expect(result.maxTemperature).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data correctly", async () => {
|
||||
const provider = new WeatherAPIProvider({
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
apiKey: "test-key",
|
||||
type: "forecast",
|
||||
maxEntries: 2,
|
||||
maxNumberOfDays: 2
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse()))
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].minTemperature).toBe(-8);
|
||||
expect(result[0].maxTemperature).toBe(-1);
|
||||
expect(result[0].weatherType).toBe("day-sprinkle");
|
||||
expect(result[0].sunrise).toBeInstanceOf(Date);
|
||||
expect(result[0].sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast data correctly", async () => {
|
||||
const provider = new WeatherAPIProvider({
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
apiKey: "test-key",
|
||||
type: "hourly",
|
||||
maxEntries: 3,
|
||||
maxNumberOfDays: 1
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse()))
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].temperature).toBe(-5);
|
||||
expect(result[0].humidity).toBe(85);
|
||||
expect(result[0].windFromDirection).toBe(210);
|
||||
expect(result[0].weatherType).toBe("night-sprinkle");
|
||||
expect(result[0].precipitationProbability).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should call error callback on invalid API response", async () => {
|
||||
const provider = new WeatherAPIProvider({
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
apiKey: "test-key",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHER_API_URL, () => HttpResponse.json({
|
||||
location: {},
|
||||
current: {},
|
||||
forecast: { forecastday: "invalid" }
|
||||
}))
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("message");
|
||||
expect(error).toHaveProperty("translationKey");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Weatherbit Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import currentData from "../../../../../mocks/weather_weatherbit.json" with { type: "json" };
|
||||
import forecastData from "../../../../../mocks/weather_weatherbit_forecast.json" with { type: "json" };
|
||||
import hourlyData from "../../../../../mocks/weather_weatherbit_hourly.json" with { type: "json" };
|
||||
|
||||
const WEATHERBIT_CURRENT_URL = "https://api.weatherbit.io/v2.0/current*";
|
||||
const WEATHERBIT_FORECAST_URL = "https://api.weatherbit.io/v2.0/forecast/daily*";
|
||||
const WEATHERBIT_HOURLY_URL = "https://api.weatherbit.io/v2.0/forecast/hourly*";
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("WeatherbitProvider", () => {
|
||||
let WeatherbitProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/weatherbit");
|
||||
WeatherbitProvider = module.default || module;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
apiKey: "test-api-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.apiKey).toBe("test-api-key");
|
||||
expect(provider.config.lat).toBe(40.71);
|
||||
expect(provider.config.lon).toBe(-74.0);
|
||||
});
|
||||
|
||||
it("should error if API key is missing", async () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error.message).toContain("API key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather data", async () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERBIT_CURRENT_URL, () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBe(1);
|
||||
expect(result.windSpeed).toBe(1.5);
|
||||
expect(result.windFromDirection).toBe(210);
|
||||
expect(result.humidity).toBe(47);
|
||||
});
|
||||
|
||||
it("should parse sunrise/sunset from HH:mm format", async () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERBIT_CURRENT_URL, () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should convert icon code to weather type", async () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERBIT_CURRENT_URL, () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.weatherType).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERBIT_FORECAST_URL, () => {
|
||||
return HttpResponse.json(forecastData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const day = result[0];
|
||||
expect(day).toHaveProperty("date");
|
||||
expect(day).toHaveProperty("minTemperature");
|
||||
expect(day).toHaveProperty("maxTemperature");
|
||||
expect(day).toHaveProperty("weatherType");
|
||||
expect(day).toHaveProperty("precipitationProbability");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should handle hourly API endpoint access error", async () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERBIT_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error.message || error).toContain("No usable data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle invalid response", async () => {
|
||||
const provider = new WeatherbitProvider({
|
||||
apiKey: "test-key",
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERBIT_CURRENT_URL, () => {
|
||||
return HttpResponse.json({ data: [] });
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* WeatherFlow Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import weatherflowData from "../../../../../mocks/weather_weatherflow.json" with { type: "json" };
|
||||
|
||||
const WEATHERFLOW_URL = "https://swd.weatherflow.com/swd/rest/better_forecast*";
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("WeatherFlowProvider", () => {
|
||||
let WeatherFlowProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/weatherflow");
|
||||
WeatherFlowProvider = module.default || module;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.token).toBe("test-token");
|
||||
expect(provider.config.stationid).toBe("12345");
|
||||
});
|
||||
|
||||
it("should error if token or stationid is missing", async () => {
|
||||
const provider = new WeatherFlowProvider({});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error.message).toContain("token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather data", async () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERFLOW_URL, () => {
|
||||
return HttpResponse.json(weatherflowData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBe(16);
|
||||
expect(result.humidity).toBe(28);
|
||||
expect(result.weatherType).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should convert wind speed from km/h to m/s", async () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERFLOW_URL, () => {
|
||||
return HttpResponse.json(weatherflowData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Wind speed 15 km/h -> ~4.17 m/s
|
||||
expect(result.windSpeed).toBeCloseTo(4.17, 1);
|
||||
});
|
||||
|
||||
it("should include sunrise/sunset", async () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERFLOW_URL, () => {
|
||||
return HttpResponse.json(weatherflowData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERFLOW_URL, () => {
|
||||
return HttpResponse.json(weatherflowData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const day = result[0];
|
||||
expect(day).toHaveProperty("date");
|
||||
expect(day).toHaveProperty("minTemperature");
|
||||
expect(day).toHaveProperty("maxTemperature");
|
||||
expect(day).toHaveProperty("weatherType");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast data", async () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERFLOW_URL, () => {
|
||||
return HttpResponse.json(weatherflowData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const hour = result[0];
|
||||
expect(hour).toHaveProperty("date");
|
||||
expect(hour).toHaveProperty("temperature");
|
||||
expect(hour).toHaveProperty("windSpeed");
|
||||
});
|
||||
|
||||
it("should aggregate UV data from hourly forecasts", async () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERFLOW_URL, () => {
|
||||
return HttpResponse.json(weatherflowData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// First day should have UV from hourly data
|
||||
expect(result[0]).toHaveProperty("uvIndex");
|
||||
expect(result[0].uvIndex).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle invalid response", async () => {
|
||||
const provider = new WeatherFlowProvider({
|
||||
token: "test-token",
|
||||
stationid: "12345",
|
||||
type: "current"
|
||||
});
|
||||
|
||||
// Invalid responses return null without calling error callback
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERFLOW_URL, () => {
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Weather.gov Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
* Weather.gov is the US National Weather Service API.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
import pointsData from "../../../../../mocks/weather_weathergov_points.json" with { type: "json" };
|
||||
import stationsData from "../../../../../mocks/weather_weathergov_stations.json" with { type: "json" };
|
||||
import currentData from "../../../../../mocks/weather_weathergov_current.json" with { type: "json" };
|
||||
import forecastData from "../../../../../mocks/weather_weathergov_forecast.json" with { type: "json" };
|
||||
import hourlyData from "../../../../../mocks/weather_weathergov_hourly.json" with { type: "json" };
|
||||
|
||||
const WEATHERGOV_POINTS_URL = "https://api.weather.gov/points/*";
|
||||
const WEATHERGOV_STATIONS_URL = "https://api.weather.gov/gridpoints/*/stations";
|
||||
const WEATHERGOV_CURRENT_URL = "https://api.weather.gov/stations/*/observations/latest";
|
||||
const WEATHERGOV_FORECAST_URL = "https://api.weather.gov/gridpoints/*/forecast*";
|
||||
const WEATHERGOV_HOURLY_URL = "https://api.weather.gov/gridpoints/*/forecast/hourly*";
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer(
|
||||
// Default handlers for initialization
|
||||
http.get(WEATHERGOV_POINTS_URL, () => {
|
||||
return HttpResponse.json(pointsData);
|
||||
}),
|
||||
http.get(WEATHERGOV_STATIONS_URL, () => {
|
||||
return HttpResponse.json(stationsData);
|
||||
})
|
||||
);
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
// Re-add default initialization handlers
|
||||
server.use(
|
||||
http.get(WEATHERGOV_POINTS_URL, () => {
|
||||
return HttpResponse.json(pointsData);
|
||||
}),
|
||||
http.get(WEATHERGOV_STATIONS_URL, () => {
|
||||
return HttpResponse.json(stationsData);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("WeatherGovProvider", () => {
|
||||
let WeatherGovProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/weathergov");
|
||||
WeatherGovProvider = module.default || module;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
expect(provider.config.lat).toBe(40.71);
|
||||
expect(provider.config.lon).toBe(-74.0);
|
||||
expect(provider.config.type).toBe("current");
|
||||
});
|
||||
|
||||
it("should have default update interval", () => {
|
||||
const provider = new WeatherGovProvider({});
|
||||
expect(provider.config.updateInterval).toBe(600000); // 10 minutes
|
||||
});
|
||||
});
|
||||
|
||||
describe("Two-Step Initialization", () => {
|
||||
it("should fetch points URL and then stations URL", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
|
||||
let pointsRequested = false;
|
||||
let stationsRequested = false;
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_POINTS_URL, () => {
|
||||
pointsRequested = true;
|
||||
return HttpResponse.json(pointsData);
|
||||
}),
|
||||
http.get(WEATHERGOV_STATIONS_URL, () => {
|
||||
stationsRequested = true;
|
||||
return HttpResponse.json(stationsData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
expect(pointsRequested).toBe(true);
|
||||
expect(stationsRequested).toBe(true);
|
||||
expect(provider.locationName).toBe("Washington, DC");
|
||||
});
|
||||
|
||||
it("should store forecast URLs after initialization", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0
|
||||
});
|
||||
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
await provider.initialize();
|
||||
|
||||
expect(provider.forecastURL).toContain("forecast?units=si");
|
||||
expect(provider.forecastHourlyURL).toContain("forecast/hourly?units=si");
|
||||
expect(provider.stationObsURL).toContain("observations/latest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather data", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_CURRENT_URL, () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.temperature).toBe(-1);
|
||||
expect(result.windSpeed).toBe(0);
|
||||
expect(result.windFromDirection).toBe(0);
|
||||
expect(result.humidity).toBe(64); // Rounded from 63.77
|
||||
expect(result.weatherType).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should use heat index or wind chill for feels like temperature", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_CURRENT_URL, () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Real data has null windChill - falls back to temperature
|
||||
expect(result.feelsLikeTemp).toBe(-1);
|
||||
});
|
||||
|
||||
it("should include sunrise/sunset", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_CURRENT_URL, () => {
|
||||
return HttpResponse.json(currentData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_FORECAST_URL, () => {
|
||||
return HttpResponse.json(forecastData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const day = result[0];
|
||||
expect(day).toHaveProperty("date");
|
||||
expect(day).toHaveProperty("minTemperature");
|
||||
expect(day).toHaveProperty("maxTemperature");
|
||||
expect(day).toHaveProperty("weatherType");
|
||||
expect(day).toHaveProperty("precipitationProbability");
|
||||
});
|
||||
|
||||
it("should not skip the first day of forecast data", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_FORECAST_URL, () => {
|
||||
return HttpResponse.json(forecastData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Mock data starts on 2026-02-06 ("This Afternoon").
|
||||
// Before the fix, slice(1) dropped today, so result[0] would have been 2026-02-07.
|
||||
const firstDate = result[0].date;
|
||||
expect(firstDate.getFullYear()).toBe(2026);
|
||||
expect(firstDate.getMonth()).toBe(1); // February (0-indexed)
|
||||
expect(firstDate.getDate()).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast data", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(156); // Real API returns 156 hourly periods
|
||||
expect(result[0]).toHaveProperty("temperature");
|
||||
expect(result[0]).toHaveProperty("windSpeed");
|
||||
expect(result[0]).toHaveProperty("windFromDirection");
|
||||
expect(result[0]).toHaveProperty("weatherType");
|
||||
});
|
||||
|
||||
it("should convert wind direction strings to degrees", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Real data has "S" wind for both hours
|
||||
expect(result[0].windFromDirection).toBe(180);
|
||||
// Third hour also has "S" wind
|
||||
expect(result[2].windFromDirection).toBe(180);
|
||||
});
|
||||
|
||||
it("should parse wind speed with units", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_HOURLY_URL, () => {
|
||||
return HttpResponse.json(hourlyData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Wind speeds should be converted from km/h to m/s
|
||||
expect(result[0].windSpeed).toBeCloseTo(1.11, 1); // Real data: 4 km/h -> ~1.11 m/s
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should categorize DNS errors as retryable", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_POINTS_URL, () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
// Should call error callback
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("message");
|
||||
});
|
||||
|
||||
it("should handle invalid JSON response", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_CURRENT_URL, () => {
|
||||
return HttpResponse.json({ properties: null });
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error.message).toContain("Invalid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weather Type Conversion", () => {
|
||||
it("should convert textDescription to weather types", async () => {
|
||||
const provider = new WeatherGovProvider({
|
||||
lat: 40.71,
|
||||
lon: -74.0,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
const testData = JSON.parse(JSON.stringify(currentData));
|
||||
testData.properties.textDescription = "Thunderstorm";
|
||||
|
||||
server.use(
|
||||
http.get(WEATHERGOV_CURRENT_URL, () => {
|
||||
return HttpResponse.json(testData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// Thunderstorm should map to day or night thunderstorm
|
||||
expect(["thunderstorm", "night-thunderstorm"]).toContain(result.weatherType);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Yr.no Weather Provider Tests
|
||||
*
|
||||
* Tests data parsing for current, forecast, and hourly weather types.
|
||||
* Yr.no is the Norwegian Meteorological Institute API.
|
||||
*
|
||||
* Uses fake timers to ensure deterministic timeseries selection.
|
||||
* The provider picks the closest past entry from timeseries based on new Date().
|
||||
* Fixed to 2026-02-06T21:30:00Z → selects timeseries[0] at 21:00 with T=-5.8°C.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
|
||||
|
||||
import yrData from "../../../../../mocks/weather_yr.json" with { type: "json" };
|
||||
|
||||
const YR_FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/**";
|
||||
const YR_SUNRISE_URL = "https://api.met.no/weatherapi/sunrise/**";
|
||||
|
||||
// Fixed time: 30 minutes after the first timeseries entry (2026-02-06T21:00:00Z)
|
||||
// This ensures timeseries[0] is always chosen as the closest past entry.
|
||||
const FAKE_NOW = new Date("2026-02-06T21:30:00Z");
|
||||
|
||||
let server;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer(
|
||||
http.get(({ request }) => request.url.includes("/locationforecast/"), () => {
|
||||
return HttpResponse.json(yrData);
|
||||
}),
|
||||
http.get(({ request }) => request.url.includes("/sunrise/"), () => {
|
||||
return HttpResponse.json({
|
||||
when: { interval: ["2026-02-06T00:00:00+01:00"] },
|
||||
properties: {
|
||||
sunrise: { time: "2026-02-06T08:30:00+01:00" },
|
||||
sunset: { time: "2026-02-06T16:30:00+01:00" }
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: FAKE_NOW });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe("YrProvider", () => {
|
||||
let YrProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../../defaultmodules/weather/providers/yr");
|
||||
YrProvider = module.default;
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91,
|
||||
lon: 10.72,
|
||||
altitude: 94
|
||||
});
|
||||
expect(provider.config.lat).toBe(59.91);
|
||||
expect(provider.config.lon).toBe(10.72);
|
||||
expect(provider.config.altitude).toBe(94);
|
||||
});
|
||||
|
||||
it("should enforce minimum 10-minute update interval", () => {
|
||||
const provider = new YrProvider({
|
||||
updateInterval: 60000 // 1 minute - too short
|
||||
});
|
||||
expect(provider.config.updateInterval).toBe(600000);
|
||||
});
|
||||
|
||||
it("should allow intervals >= 10 minutes", () => {
|
||||
const provider = new YrProvider({
|
||||
updateInterval: 900000 // 15 minutes
|
||||
});
|
||||
expect(provider.config.updateInterval).toBe(900000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coordinate Validation", () => {
|
||||
it("should limit coordinates to 4 decimal places", async () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91234567,
|
||||
lon: 10.72345678
|
||||
});
|
||||
provider.setCallbacks(vi.fn(), vi.fn());
|
||||
|
||||
server.use(
|
||||
http.get(YR_FORECAST_URL, () => {
|
||||
return HttpResponse.json(yrData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
|
||||
expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(4);
|
||||
expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Weather Parsing", () => {
|
||||
it("should parse current weather from timeseries", async () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91,
|
||||
lon: 10.72,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(YR_FORECAST_URL, () => {
|
||||
return HttpResponse.json(yrData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// With fake time at 21:30, provider selects timeseries[0] (21:00 UTC)
|
||||
expect(result.temperature).toBe(-5.8);
|
||||
expect(result.windSpeed).toBe(6.0);
|
||||
expect(result.windFromDirection).toBe(37.0);
|
||||
expect(result.humidity).toBe(66.5);
|
||||
// 21:00 is after sunset (16:30), symbol_code "snow" maps to "snow"
|
||||
expect(result.weatherType).toBe("snow");
|
||||
});
|
||||
|
||||
it("should include sunrise/sunset from stellar data", async () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91,
|
||||
lon: 10.72,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.sunrise).toBeInstanceOf(Date);
|
||||
expect(result.sunset).toBeInstanceOf(Date);
|
||||
expect(result.sunset.getTime()).toBeGreaterThan(result.sunrise.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forecast Parsing", () => {
|
||||
it("should parse daily forecast data", async () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91,
|
||||
lon: 10.72,
|
||||
type: "forecast"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(YR_FORECAST_URL, () => {
|
||||
return HttpResponse.json(yrData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const day = result[0];
|
||||
expect(day).toHaveProperty("date");
|
||||
expect(day).toHaveProperty("minTemperature");
|
||||
expect(day).toHaveProperty("maxTemperature");
|
||||
expect(day.minTemperature).toBeLessThanOrEqual(day.maxTemperature);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hourly Parsing", () => {
|
||||
it("should parse hourly forecast data", async () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91,
|
||||
lon: 10.72,
|
||||
type: "hourly"
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(YR_FORECAST_URL, () => {
|
||||
return HttpResponse.json(yrData);
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const hour = result[0];
|
||||
expect(hour).toHaveProperty("temperature");
|
||||
expect(hour).toHaveProperty("windSpeed");
|
||||
expect(hour).toHaveProperty("precipitationAmount");
|
||||
expect(hour).toHaveProperty("weatherType");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should call error callback on invalid data", async () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91,
|
||||
lon: 10.72,
|
||||
type: "current"
|
||||
});
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(vi.fn(), resolve);
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get(YR_FORECAST_URL, () => {
|
||||
return HttpResponse.json({ properties: {} });
|
||||
})
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weather Type Conversion", () => {
|
||||
it("should convert yr symbol codes correctly", async () => {
|
||||
const provider = new YrProvider({
|
||||
lat: 59.91,
|
||||
lon: 10.72,
|
||||
type: "current",
|
||||
currentForecastHours: 1
|
||||
});
|
||||
|
||||
const dataPromise = new Promise((resolve) => {
|
||||
provider.setCallbacks(resolve, vi.fn());
|
||||
});
|
||||
|
||||
// Uses yrData from beforeAll which has symbol_code "snow"
|
||||
|
||||
await provider.initialize();
|
||||
provider.start();
|
||||
|
||||
const result = await dataPromise;
|
||||
|
||||
// 21:00 is after sunset (16:30), next_1_hours symbol_code is "snow"
|
||||
expect(result.weatherType).toBe("snow");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
const WeatherObject = require("../../../../../modules/default/weather/weatherobject");
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const WeatherObject = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherobject`);
|
||||
|
||||
global.moment = require("moment-timezone");
|
||||
global.SunCalc = require("suncalc");
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Weather Provider Smoke Tests
|
||||
*
|
||||
* Tests basic provider functionality: configuration, callbacks, and validation.
|
||||
* Parser logic with private methods (#) is validated through live testing.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest";
|
||||
|
||||
// Mock global fetch for location lookup
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
global.fetch = vi.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ city: "Munich", locality: "Munich" })
|
||||
}));
|
||||
|
||||
// Restore original fetch after all tests
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("Weather Provider Smoke Tests", () => {
|
||||
describe("OpenMeteoProvider", () => {
|
||||
let OpenMeteoProvider;
|
||||
let provider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../defaultmodules/weather/providers/openmeteo");
|
||||
OpenMeteoProvider = module.default;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new OpenMeteoProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
type: "current",
|
||||
updateInterval: 600000
|
||||
});
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
expect(provider.config.lat).toBe(48.14);
|
||||
expect(provider.config.lon).toBe(11.58);
|
||||
expect(provider.config.type).toBe("current");
|
||||
expect(provider.config.updateInterval).toBe(600000);
|
||||
});
|
||||
|
||||
it("should have default values", () => {
|
||||
const defaultProvider = new OpenMeteoProvider({});
|
||||
expect(defaultProvider.config.lat).toBe(0);
|
||||
expect(defaultProvider.config.lon).toBe(0);
|
||||
expect(defaultProvider.config.type).toBe("current");
|
||||
expect(defaultProvider.config.maxNumberOfDays).toBe(5);
|
||||
});
|
||||
|
||||
it("should accept all supported types", () => {
|
||||
expect(new OpenMeteoProvider({ type: "current" }).config.type).toBe("current");
|
||||
expect(new OpenMeteoProvider({ type: "forecast" }).config.type).toBe("forecast");
|
||||
expect(new OpenMeteoProvider({ type: "hourly" }).config.type).toBe("hourly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Callback Interface", () => {
|
||||
it("should store callbacks via setCallbacks", () => {
|
||||
const onData = vi.fn();
|
||||
const onError = vi.fn();
|
||||
provider.setCallbacks(onData, onError);
|
||||
expect(provider.onDataCallback).toBe(onData);
|
||||
expect(provider.onErrorCallback).toBe(onError);
|
||||
});
|
||||
|
||||
it("should initialize without callbacks", async () => {
|
||||
await expect(provider.initialize()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Public Methods", () => {
|
||||
it("should have start/stop methods", () => {
|
||||
expect(typeof provider.start).toBe("function");
|
||||
expect(typeof provider.stop).toBe("function");
|
||||
});
|
||||
|
||||
it("should have initialize method", () => {
|
||||
expect(typeof provider.initialize).toBe("function");
|
||||
});
|
||||
|
||||
it("should have setCallbacks method", () => {
|
||||
expect(typeof provider.setCallbacks).toBe("function");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenWeatherMapProvider", () => {
|
||||
let OpenWeatherMapProvider;
|
||||
let provider;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../../../../defaultmodules/weather/providers/openweathermap");
|
||||
OpenWeatherMapProvider = module.default;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: "test-api-key",
|
||||
type: "current"
|
||||
});
|
||||
});
|
||||
|
||||
describe("Constructor & Configuration", () => {
|
||||
it("should set config values from params", () => {
|
||||
expect(provider.config.lat).toBe(48.14);
|
||||
expect(provider.config.lon).toBe(11.58);
|
||||
expect(provider.config.apiKey).toBe("test-api-key");
|
||||
expect(provider.config.type).toBe("current");
|
||||
});
|
||||
|
||||
it("should have default values", () => {
|
||||
const defaultProvider = new OpenWeatherMapProvider({ apiKey: "test" });
|
||||
expect(defaultProvider.config.apiVersion).toBe("3.0");
|
||||
expect(defaultProvider.config.weatherEndpoint).toBe("/onecall");
|
||||
expect(defaultProvider.config.apiBase).toBe("https://api.openweathermap.org/data/");
|
||||
});
|
||||
|
||||
it("should accept all supported types", () => {
|
||||
expect(new OpenWeatherMapProvider({ apiKey: "test", type: "current" }).config.type).toBe("current");
|
||||
expect(new OpenWeatherMapProvider({ apiKey: "test", type: "forecast" }).config.type).toBe("forecast");
|
||||
expect(new OpenWeatherMapProvider({ apiKey: "test", type: "hourly" }).config.type).toBe("hourly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Key Validation", () => {
|
||||
it("should call onErrorCallback if no API key provided", async () => {
|
||||
const noKeyProvider = new OpenWeatherMapProvider({
|
||||
lat: 48.14,
|
||||
lon: 11.58,
|
||||
apiKey: ""
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
noKeyProvider.setCallbacks(vi.fn(), onError);
|
||||
await noKeyProvider.initialize();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "API key is required"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should not create fetcher without API key", async () => {
|
||||
const noKeyProvider = new OpenWeatherMapProvider({
|
||||
apiKey: ""
|
||||
});
|
||||
noKeyProvider.setCallbacks(vi.fn(), vi.fn());
|
||||
await noKeyProvider.initialize();
|
||||
|
||||
expect(noKeyProvider.fetcher).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Callback Interface", () => {
|
||||
it("should store callbacks via setCallbacks", () => {
|
||||
const onData = vi.fn();
|
||||
const onError = vi.fn();
|
||||
provider.setCallbacks(onData, onError);
|
||||
expect(provider.onDataCallback).toBe(onData);
|
||||
expect(provider.onErrorCallback).toBe(onError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Public Methods", () => {
|
||||
it("should have start/stop methods", () => {
|
||||
expect(typeof provider.start).toBe("function");
|
||||
expect(typeof provider.stop).toBe("function");
|
||||
});
|
||||
|
||||
it("should have initialize method", () => {
|
||||
expect(typeof provider.initialize).toBe("function");
|
||||
});
|
||||
|
||||
it("should have setCallbacks method", () => {
|
||||
expect(typeof provider.setCallbacks).toBe("function");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
const weather = require("../../../../../modules/default/weather/weatherutils");
|
||||
const WeatherUtils = require("../../../../../modules/default/weather/weatherutils");
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const weather = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherutils`);
|
||||
const WeatherUtils = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherutils`);
|
||||
|
||||
describe("Weather utils tests", () => {
|
||||
describe("temperature conversion to imperial", () => {
|
||||
|
||||
Reference in New Issue
Block a user