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:
Karsten Hassel
2026-04-01 12:35:16 +02:00
committed by GitHub
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
294 changed files with 35941 additions and 10259 deletions
@@ -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=");
});
});
+4 -104
View File
@@ -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", () => {