chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:19:24 +08:00
commit dbe3ade0dc
449 changed files with 71828 additions and 0 deletions
@@ -0,0 +1,130 @@
global.moment = require("moment-timezone");
const ical = require("node-ical");
const moment = require("moment-timezone");
const defaults = require("../../../../../js/defaults");
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
const CalendarFetcher = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcher`);
const makeFetcher = (options = {}) => new CalendarFetcher(
options.url ?? "http://test.example.com/cal.ics",
options.reloadInterval ?? 60000,
options.excludedEvents ?? [],
options.maximumEntries ?? 10,
options.maximumNumberOfDays ?? 365,
options.auth ?? null,
options.includePastEvents ?? false,
options.selfSignedCert ?? false
);
// Triggers a fetch and resolves once the fetcher finishes (success or error).
// On error, resolves with the errorInfo object so tests can inspect it.
const emitResponse = (fetcher, response) => new Promise((resolve) => {
fetcher.onReceive(resolve);
fetcher.onError((_, errorInfo) => resolve(errorInfo));
fetcher.httpFetcher.emit("response", response);
});
const futureEventICS = () => {
const start = moment().add(1, "hour");
const end = moment().add(2, "hours");
return [
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
`DTSTART:${start.utc().format("YYYYMMDDTHHmmss")}Z`,
`DTEND:${end.utc().format("YYYYMMDDTHHmmss")}Z`,
"UID:future-1@test",
"SUMMARY:Future Event",
"END:VEVENT",
"END:VCALENDAR"
].join("\r\n");
};
describe("CalendarFetcher", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("304 handling", () => {
it("keeps previously fetched events when a 304 Not Modified response arrives", async () => {
const fetcher = makeFetcher();
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
expect(fetcher.events).toHaveLength(1);
// 304 Not Modified has an empty body: events must be preserved
await emitResponse(fetcher, new Response(null, { status: 304 }));
expect(fetcher.events).toHaveLength(1);
});
});
describe("error handling", () => {
it("forwards HTTP fetch errors to onError callback", () => {
const fetcher = makeFetcher();
const onError = vi.fn();
const errorInfo = { errorType: "NETWORK_ERROR", message: "boom" };
fetcher.onError(onError);
fetcher.httpFetcher.emit("error", errorInfo);
expect(onError).toHaveBeenCalledWith(fetcher, errorInfo);
});
it("keeps existing events and reports PARSE_ERROR when parsing fails", async () => {
const fetcher = makeFetcher();
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
expect(fetcher.events).toHaveLength(1);
vi.spyOn(ical.async, "parseICS").mockRejectedValueOnce(new Error("invalid ics"));
const error = await emitResponse(fetcher, new Response("BROKEN", { status: 200 }));
expect(fetcher.events).toHaveLength(1);
expect(error).toMatchObject({
errorType: "PARSE_ERROR",
translationKey: "MODULE_ERROR_UNSPECIFIED",
url: "http://test.example.com/cal.ics"
});
});
});
describe("delegation and refetch", () => {
it("delegates fetchCalendar to HTTPFetcher.startPeriodicFetch", () => {
const fetcher = makeFetcher();
const startSpy = vi.spyOn(fetcher.httpFetcher, "startPeriodicFetch");
fetcher.fetchCalendar();
expect(startSpy).toHaveBeenCalledTimes(1);
});
it("shouldRefetch respects reload interval boundaries", () => {
const fetcher = makeFetcher();
expect(fetcher.shouldRefetch()).toBe(true);
fetcher.lastFetch = Date.now() - 59999;
expect(fetcher.shouldRefetch()).toBe(false);
fetcher.lastFetch = Date.now() - 60000;
expect(fetcher.shouldRefetch()).toBe(true);
});
it("passes configured filter options to CalendarFetcherUtils.filterEvents", async () => {
const excludedEvents = ["Do not show me"];
const filterSpy = vi.spyOn(CalendarFetcherUtils, "filterEvents");
const fetcher = makeFetcher({ excludedEvents, maximumEntries: 7, maximumNumberOfDays: 30, includePastEvents: true });
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
expect(filterSpy).toHaveBeenCalledWith(expect.any(Object), {
excludedEvents,
includePastEvents: true,
maximumEntries: 7,
maximumNumberOfDays: 30
});
});
});
});
@@ -0,0 +1,32 @@
global.moment = require("moment-timezone");
const defaults = require("../../../js/defaults");
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
describe("Calendar fetcher utils test", () => {
const defaultConfig = {
excludedEvents: []
};
describe("filterEvents", () => {
it("no events, not crash", () => {
const base = moment().startOf("day").add(12, "hours");
const minusOneHour = base.clone().subtract(1, "hours").toDate();
const minusTwoHours = base.clone().subtract(2, "hours").toDate();
const plusOneHour = base.clone().add(1, "hours").toDate();
const plusTwoHours = base.clone().add(2, "hours").toDate();
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" },
ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" },
upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" }
},
defaultConfig
);
expect(filteredEvents).toHaveLength(0);
});
});
});
@@ -0,0 +1,523 @@
global.moment = require("moment-timezone");
const ical = require("node-ical");
const moment = require("moment-timezone");
const defaults = require("../../../../../js/defaults");
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
describe("Calendar fetcher utils test", () => {
const defaultConfig = {
excludedEvents: [],
includePastEvents: false,
maximumEntries: 10,
maximumNumberOfDays: 367
};
describe("filterEvents", () => {
it("should return only ongoing and upcoming non full day events", () => {
const minusOneHour = moment().subtract(1, "hours").toDate();
const minusTwoHours = moment().subtract(2, "hours").toDate();
const plusOneHour = moment().add(1, "hours").toDate();
const plusTwoHours = moment().add(2, "hours").toDate();
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" },
ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" },
upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" }
},
defaultConfig
);
expect(filteredEvents).toHaveLength(2);
expect(filteredEvents[0].title).toBe("ongoingEvent");
expect(filteredEvents[1].title).toBe("upcomingEvent");
});
it("should return only ongoing and upcoming full day events", () => {
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: today, summary: "pastEvent" },
ongoingEvent: { type: "VEVENT", start: today, end: tomorrow, summary: "ongoingEvent" },
upcomingEvent: { type: "VEVENT", start: tomorrow, end: dayAfterTomorrow, summary: "upcomingEvent" }
},
defaultConfig
);
expect(filteredEvents).toHaveLength(2);
expect(filteredEvents[0].title).toBe("ongoingEvent");
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
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 filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig);
const januaryFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "01-01");
const julyFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "07-01");
let januaryMoment = moment(`${moment(januaryFirst[0].startDate, "x").format("YYYY")}-01-01T09:00:00`)
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
let julyMoment = moment(`${moment(julyFirst[0].startDate, "x").format("YYYY")}-07-01T09:00:00`)
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
expect(januaryFirst[0].startDate).toEqual(januaryMoment.format("x"));
expect(julyFirst[0].startDate).toEqual(julyMoment.format("x"));
});
it("should return the correct moments based on the timezone given", () => {
const data = ical.parseICS(`BEGIN:VEVENT
DTSTART;TZID=Europe/Amsterdam:20250311T090000
DTEND;TZID=Europe/Amsterdam:20250311T091500
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
DTSTAMP:20250531T091103Z
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
SUMMARY:Test
END:VEVENT`);
const instances = CalendarFetcherUtils.expandRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days"));
const januaryFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "01-01");
const julyFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "07-01");
// 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("calculateFilterWindow", () => {
it("ends maximumNumberOfDays after today's midnight", () => {
const [, end] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
expect(end).toEqual(moment().startOf("day").add(30, "days").toDate());
});
it("starts now when includePastEvents is false", () => {
const before = Date.now();
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
const after = Date.now();
expect(start.getTime()).toBeGreaterThanOrEqual(before);
expect(start.getTime()).toBeLessThanOrEqual(after);
});
it("starts maximumNumberOfDays before today's midnight when includePastEvents is true", () => {
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: true, maximumNumberOfDays: 30 });
expect(start).toEqual(moment().startOf("day").subtract(30, "days").toDate());
});
});
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");
});
});
});
@@ -0,0 +1,155 @@
global.moment = require("moment");
const defaults = require("../../../../../js/defaults");
const CalendarUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarutils`);
describe("Calendar utils tests", () => {
describe("capFirst", () => {
const words = {
rodrigo: "Rodrigo",
"123m": "123m",
"magic mirror": "Magic mirror",
",a": ",a",
ñandú: "Ñandú"
};
Object.keys(words).forEach((word) => {
it(`for '${word}' should return '${words[word]}'`, () => {
expect(CalendarUtils.capFirst(word)).toBe(words[word]);
});
});
it("should not capitalize other letters", () => {
expect(CalendarUtils.capFirst("event")).not.toBe("EVent");
});
});
describe("getLocaleSpecification", () => {
it("should return a valid moment.LocaleSpecification for a 12-hour format", () => {
expect(CalendarUtils.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: "h:mm A" } });
});
it("should return a valid moment.LocaleSpecification for a 24-hour format", () => {
expect(CalendarUtils.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: "HH:mm" } });
});
it("should return the current system locale when called without timeFormat number", () => {
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat("LT") } });
});
it("should return a 12-hour longDateFormat when using the 'en' locale", () => {
const localeBackup = moment.locale();
moment.locale("en");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 12-hour longDateFormat when using the 'au' locale", () => {
const localeBackup = moment.locale();
moment.locale("au");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 12-hour longDateFormat when using the 'eg' locale", () => {
const localeBackup = moment.locale();
moment.locale("eg");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'nl' locale", () => {
const localeBackup = moment.locale();
moment.locale("nl");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'fr' locale", () => {
const localeBackup = moment.locale();
moment.locale("fr");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'uk' locale", () => {
const localeBackup = moment.locale();
moment.locale("uk");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
});
describe("shorten", () => {
it("should not shorten if short enough", () => {
expect(CalendarUtils.shorten("Event 1", 10, false, 1)).toBe("Event 1");
});
it("should shorten into one line", () => {
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 1)).toBe("Example …");
});
it("should shorten into three lines", () => {
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 3)).toBe("Example <br>event at 12 o <br>clock");
});
it("should not shorten into three lines if wrap is false", () => {
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, false, 3)).toBe("Example ev…");
});
const strings = {
" String with whitespace at the beginning that needs trimming": { length: 16, return: "String with whit…" },
"long string that needs shortening": { length: 16, return: "long string that…" },
"short string": { length: 16, return: "short string" },
"long string with no maxLength defined": { return: "long string with no maxLength defined" }
};
Object.keys(strings).forEach((string) => {
it(`for '${string}' should return '${strings[string].return}'`, () => {
expect(CalendarUtils.shorten(string, strings[string].length)).toBe(strings[string].return);
});
});
it("should return an empty string if shorten is called with a non-string", () => {
expect(CalendarUtils.shorten(100)).toBe("");
});
it("should not shorten the string if shorten is called with a non-number maxLength", () => {
expect(CalendarUtils.shorten("This is a test string", "This is not a number")).toBe("This is a test string");
});
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)", () => {
expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", 20, true)).toBe(
"This is a <br>wrapEvent test. Should wrap <br>the string instead of <br>shorten it if called with <br>wrapEvent = true"
);
});
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)", () => {
expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", undefined, true)).toBe(
"This is a wrapEvent <br>test. Should wrap the string <br>instead of shorten it if called <br>with wrapEvent = true"
);
});
it("should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", () => {
expect(CalendarUtils.shorten("This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", undefined, true, 2)).toBe(
"This is a wrapEvent and <br>maxTitleLines test. Should wrap and …"
);
});
});
describe("titleTransform and shorten combined", () => {
it("should replace the birthday and wrap nicely", () => {
const transformedTitle = CalendarUtils.titleTransform("Michael Teeuw's birthday", [{ search: "'s birthday", replace: "" }]);
expect(CalendarUtils.shorten(transformedTitle, 10, true, 2)).toBe("Michael <br>Teeuw");
});
});
describe("titleTransform with yearmatchgroup", () => {
it("should replace the birthday and wrap nicely", () => {
const transformedTitle = CalendarUtils.titleTransform("Luciella '2000", [{ search: "^([^']*) '(\\d{4})$", replace: "$1 ($2.)", yearmatchgroup: 2 }]);
const expectedResult = `Luciella (${new Date().getFullYear() - 2000}.)`;
expect(transformedTitle).toBe(expectedResult);
});
});
});
@@ -0,0 +1,153 @@
describe("Compliments module", () => {
let complimentsModule;
beforeEach(() => {
// Mock global dependencies
global.Module = {
register: vi.fn((name, moduleDefinition) => {
complimentsModule = moduleDefinition;
})
};
global.Log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
};
global.Cron = vi.fn();
// Load the module
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/${defaults.defaultModulesDir}/compliments/${path}`);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("loadComplimentFile", () => {
describe("HTTP error handling", () => {
it("should return null and log error on HTTP 404", async () => {
complimentsModule.config.remoteFile = "http://example.com/missing.json";
global.fetch = vi.fn(() => Promise.resolve({
ok: false,
status: 404,
statusText: "Not Found"
}));
const result = await complimentsModule.loadComplimentFile();
expect(result).toBeNull();
expect(Log.error).toHaveBeenCalledWith("[compliments] HTTP error: 404 Not Found");
});
it("should return null and log error on HTTP 500", async () => {
complimentsModule.config.remoteFile = "http://example.com/error.json";
global.fetch = vi.fn(() => Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error"
}));
const result = await complimentsModule.loadComplimentFile();
expect(result).toBeNull();
expect(Log.error).toHaveBeenCalledWith("[compliments] HTTP error: 500 Internal Server Error");
});
it("should return content on successful HTTP 200", async () => {
complimentsModule.config.remoteFile = "http://example.com/compliments.json";
const expectedContent = "{\"anytime\":[\"Test compliment\"]}";
global.fetch = vi.fn(() => Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(expectedContent)
}));
const result = await complimentsModule.loadComplimentFile();
expect(result).toBe(expectedContent);
expect(Log.error).not.toHaveBeenCalled();
});
});
describe("Cache-busting with query parameters", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("should add cache-busting parameter to URL without query params", async () => {
complimentsModule.config.remoteFile = "http://example.com/compliments.json";
complimentsModule.config.remoteFileRefreshInterval = 60000;
global.fetch = vi.fn(() => Promise.resolve({
ok: true,
text: () => Promise.resolve("{}")
}));
await complimentsModule.loadComplimentFile();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("?dummy=1735732800000"));
});
it("should add cache-busting parameter to URL with existing query params", async () => {
complimentsModule.config.remoteFile = "http://example.com/compliments.json?lang=en";
complimentsModule.config.remoteFileRefreshInterval = 60000;
global.fetch = vi.fn(() => Promise.resolve({
ok: true,
text: () => Promise.resolve("{}")
}));
await complimentsModule.loadComplimentFile();
const calledUrl = fetch.mock.calls[0][0];
expect(calledUrl).toContain("lang=en");
expect(calledUrl).toContain("&dummy=1735732800000");
expect(calledUrl).not.toContain("?dummy=");
});
it("should not add cache-busting parameter when remoteFileRefreshInterval is 0", async () => {
complimentsModule.config.remoteFile = "http://example.com/compliments.json";
complimentsModule.config.remoteFileRefreshInterval = 0;
global.fetch = vi.fn(() => Promise.resolve({
ok: true,
text: () => Promise.resolve("{}")
}));
await complimentsModule.loadComplimentFile();
expect(fetch).toHaveBeenCalledWith("http://example.com/compliments.json");
});
it("should not add cache-busting parameter to local files", async () => {
complimentsModule.config.remoteFile = "compliments.json";
complimentsModule.config.remoteFileRefreshInterval = 60000;
global.fetch = vi.fn(() => Promise.resolve({
ok: true,
text: () => Promise.resolve("{}")
}));
await complimentsModule.loadComplimentFile();
const calledUrl = fetch.mock.calls[0][0];
expect(calledUrl).toBe("http://localhost:8080/defaultmodules/compliments/compliments.json");
expect(calledUrl).not.toContain("dummy=");
});
});
});
});
@@ -0,0 +1,83 @@
const defaults = require("../../../../../js/defaults");
const NewsfeedFetcher = require(`../../../../../${defaults.defaultModulesDir}/newsfeed/newsfeedfetcher`);
// The full safe list users may opt into; most tests run with it enabled.
const ALL_TAGS = ["b", "strong", "i", "em", "u", "br", "code", "s", "sub", "sup"];
const sanitize = (html, allowedTags = ALL_TAGS) => NewsfeedFetcher.sanitizeBasicHtml(html, allowedTags);
describe("NewsfeedFetcher.sanitizeBasicHtml", () => {
it("keeps real basic formatting tags", () => {
expect(sanitize("<b>a</b> <strong>b</strong> <i>c</i> <em>d</em> <u>e</u>"))
.toBe("<b>a</b> <strong>b</strong> <i>c</i> <em>d</em> <u>e</u>");
});
it("keeps the additional safe tags (code, s, sub, sup)", () => {
expect(sanitize("<code>x</code> <s>y</s> <sub>z</sub> <sup>w</sup>"))
.toBe("<code>x</code> <s>y</s> <sub>z</sub> <sup>w</sup>");
});
it("renders entity-encoded formatting tags (e.g. The Atlantic feed)", () => {
// Feeds like theatlantic.com ship emphasis as escaped entities
expect(sanitize("the &lt;em&gt;Atlantic&lt;/em&gt; ocean")).toBe("the <em>Atlantic</em> ocean");
});
it("handles emphasis inside titles regardless of how the parser delivers it", () => {
// The Atlantic uses <em> in titles, e.g. "That's Enough, <em>Euphoria</em>"
const expected = "Thats Enough, <em>Euphoria</em>";
expect(sanitize("Thats Enough, <em>Euphoria</em>")).toBe(expected);
expect(sanitize("Thats Enough, &lt;em&gt;Euphoria&lt;/em&gt;")).toBe(expected);
});
it("strips attributes from allowed tags", () => {
const result = sanitize("<b onclick=\"steal()\" class=\"x\">bold</b>");
expect(result).toBe("<b>bold</b>");
expect(result).not.toContain("onclick");
expect(result).not.toContain("class");
});
it("neutralizes script tags", () => {
expect(sanitize("<script>alert(1)</script>hello")).not.toContain("<script");
// Entity-encoded scripts must stay inert text, never become live markup
const encoded = sanitize("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(encoded).not.toContain("<script");
expect(encoded).toContain("&lt;script&gt;");
});
it("drops images and link hrefs but keeps disallowed-tag text", () => {
const result = sanitize("<img src=\"x\" onerror=\"alert(1)\"><a href=\"https://evil.example\">link</a><h1>title</h1>");
expect(result).not.toContain("onerror");
expect(result).not.toContain("href");
expect(result).not.toContain("<h1>");
expect(result).toContain("link");
expect(result.toLowerCase()).toContain("title");
});
it("escapes bare HTML special characters in plain text", () => {
expect(sanitize("Fish &amp; Chips for &lt; 5")).toBe("Fish &amp; Chips for &lt; 5");
});
it("only keeps tags present in the supplied allowlist", () => {
// Allow just <em>: a safe-but-not-allowed <strong> must become plain text.
const result = sanitize("<em>kept</em> <strong>dropped</strong>", ["em"]);
expect(result).toBe("<em>kept</em> dropped");
expect(result).not.toContain("<strong>");
});
it("escapes everything when the allowlist is empty", () => {
expect(sanitize("<em>hi</em> &amp; <b>bye</b>", [])).toBe("hi &amp; bye");
});
it("renders <br> as a single self-closing tag when allowed", () => {
const result = sanitize("a<br>b", ["br"]);
expect(result).toContain("<br>");
expect(result).not.toContain("<br></br>");
expect(result).not.toContain("&lt;br&gt;");
});
it("collapses <br> to a space when not allowed", () => {
const result = sanitize("a<br>b", ["em"]);
expect(result).not.toContain("<br>");
expect(result).toBe("a b");
});
});
+54
View File
@@ -0,0 +1,54 @@
global.moment = require("moment-timezone");
const defaults = require("../../../../js/defaults");
const { formatTime } = require(`../../../../${defaults.defaultModulesDir}/utils`);
describe("Default modules utils tests", () => {
describe("formatTime", () => {
const time = new Date();
beforeEach(() => {
time.setHours(13, 13);
});
it("should convert correctly according to the config", () => {
expect(
formatTime(
{
timeFormat: 24
},
time
)
).toBe("13:13");
expect(
formatTime(
{
showPeriod: true,
showPeriodUpper: true,
timeFormat: 12
},
time
)
).toBe("1:13 PM");
expect(
formatTime(
{
showPeriod: true,
showPeriodUpper: false,
timeFormat: 12
},
time
)
).toBe("1:13 pm");
expect(
formatTime(
{
showPeriod: false,
timeFormat: 12
},
time
)
).toBe("1:13");
});
});
});
@@ -0,0 +1,116 @@
import Module from "node:module";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Creates a fresh weather node helper instance with isolated mocks.
* @returns {Promise<object>} The mocked weather node helper.
*/
async function loadWeatherNodeHelper () {
vi.resetModules();
const loggerMock = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn()
};
const originalRequire = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === "node_helper") {
return {
create: vi.fn((definition) => definition)
};
}
if (id === "logger") {
return loggerMock;
}
return originalRequire.apply(this, arguments);
};
let helper;
try {
const helperModule = await import("../../../../../defaultmodules/weather/node_helper");
helper = helperModule.default || helperModule;
} finally {
Module.prototype.require = originalRequire;
}
helper.providers = {};
helper.lastData = {};
helper.sendSocketNotification = vi.fn();
return helper;
}
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
});
describe("weather node_helper reconnect handling", () => {
it("re-sends cached weather data when a client reconnects", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
const cachedPayload = {
instanceId,
type: "current",
data: { temperature: 8.5 }
};
helper.providers[instanceId] = { locationName: "Munich, BY" };
helper.lastData[instanceId] = cachedPayload;
await helper.initWeatherProvider({
weatherProvider: "openmeteo",
instanceId,
type: "current"
});
expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(1, "WEATHER_INITIALIZED", {
instanceId,
locationName: "Munich, BY"
});
expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(2, "WEATHER_DATA", cachedPayload);
expect(helper.sendSocketNotification).toHaveBeenCalledTimes(2);
});
it("does not send WEATHER_DATA on reconnect when no cached payload exists", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
helper.providers[instanceId] = { locationName: "Munich, BY" };
await helper.initWeatherProvider({
weatherProvider: "openmeteo",
instanceId,
type: "current"
});
expect(helper.sendSocketNotification).toHaveBeenCalledWith("WEATHER_INITIALIZED", {
instanceId,
locationName: "Munich, BY"
});
expect(helper.sendSocketNotification).toHaveBeenCalledTimes(1);
});
it("cleans up provider and cached data when stopping an instance", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
const stop = vi.fn();
helper.providers[instanceId] = { stop };
helper.lastData[instanceId] = {
instanceId,
type: "current",
data: { temperature: 8.5 }
};
helper.stopWeatherProvider(instanceId);
expect(stop).toHaveBeenCalledTimes(1);
expect(helper.providers[instanceId]).toBeUndefined();
expect(helper.lastData[instanceId]).toBeUndefined();
});
});
@@ -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,243 @@
/**
* Buienradar Provider Tests
*
* Tests data parsing for current, forecast, and hourly weather types.
* Buienradar covers NL/BE only, metric system, no API key required.
*/
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
const BUIENRADAR_URL = "https://forecast.buienradar.nl/2.0/forecast/*";
/**
* Builds a stable Buienradar mock payload for parsing tests.
* @returns {object} Buienradar forecast response fixture.
*/
function buildBuienradarResponse () {
const today = "2100-01-01";
const tomorrow = "2100-01-02";
const makeHour = (datetime, overrides = {}) => ({
datetime,
temperature: 5,
feeltemperature: 3,
humidity: 80,
windspeedms: 4,
winddirectiondegrees: 180,
precipitationmm: 0.5,
precipitation: 60,
iconcode: "a",
...overrides
});
return {
location: {
name: "Rotterdam",
lat: 51.92,
lon: 4.48
},
days: [
{
date: today,
sunrise: `${today}T07:00:00`,
sunset: `${today}T17:00:00`,
mintemp: 1,
maxtemp: 8,
humidity: 75,
windspeedms: 5,
winddirectiondegrees: 200,
precipitationmm: 2.5,
precipitation: 70,
iconcode: "b",
hours: [
makeHour(`${today}T12:00:00`, { temperature: 6, iconcode: "a" }),
makeHour(`${today}T13:00:00`, { temperature: 7, iconcode: "b" }),
makeHour(`${today}T14:00:00`, { temperature: 7.5, iconcode: "c" })
]
},
{
date: tomorrow,
sunrise: `${tomorrow}T07:01:00`,
sunset: `${tomorrow}T17:02:00`,
mintemp: 0,
maxtemp: 6,
windspeedms: 6,
winddirectiondegrees: 220,
precipitationmm: 1.0,
precipitation: 40,
iconcode: "f",
hours: []
}
]
};
}
let server;
beforeAll(() => {
server = setupServer();
server.listen({ onUnhandledRequest: "bypass" });
});
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
});
describe("BuienradarProvider", () => {
let BuienradarProvider;
beforeAll(async () => {
const module = await import("../../../../../../defaultmodules/weather/providers/buienradar");
BuienradarProvider = module.default;
});
describe("Constructor & Configuration", () => {
it("should apply defaults and merge params", () => {
const provider = new BuienradarProvider({ locationId: 6275, type: "hourly" });
expect(provider.config.apiBase).toBe("https://forecast.buienradar.nl/2.0/forecast");
expect(provider.config.locationId).toBe(6275);
expect(provider.config.type).toBe("hourly");
expect(provider.config.updateInterval).toBe(10 * 60 * 1000);
});
it("should call error callback when locationId is missing", () => {
const provider = new BuienradarProvider({});
const onError = vi.fn();
provider.setCallbacks(vi.fn(), onError);
provider.initialize();
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
translationKey: "MODULE_ERROR_UNSPECIFIED"
}));
expect(provider.fetcher).toBeNull();
});
});
describe("Current Weather Parsing", () => {
it("should parse current weather, set locationName, and merge day metadata", async () => {
const provider = new BuienradarProvider({ locationId: 6275, type: "current" });
const dataPromise = new Promise((resolve, reject) => {
provider.setCallbacks(resolve, reject);
});
server.use(
http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse()))
);
provider.initialize();
provider.start();
const result = await dataPromise;
expect(provider.locationName).toBe("Rotterdam");
expect(typeof result.temperature).toBe("number");
expect(result.humidity).toBe(80);
expect(result.windSpeed).toBe(4);
expect(result.windFromDirection).toBe(180);
expect(result.minTemperature).toBe(1);
expect(result.maxTemperature).toBe(8);
expect(result.precipitationAmount).toBe(0.5);
expect(result.precipitationProbability).toBe(60);
expect(result.precipitationUnits).toBe("mm");
expect(result.sunrise).toBeInstanceOf(Date);
expect(result.sunset).toBeInstanceOf(Date);
expect(typeof result.weatherType).toBe("string");
});
});
describe("Forecast Parsing", () => {
it("should parse daily forecast data", async () => {
const provider = new BuienradarProvider({ locationId: 6275, type: "forecast", maxEntries: 2 });
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse()))
);
provider.initialize();
provider.start();
const result = await dataPromise;
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0].minTemperature).toBe(1);
expect(result[0].maxTemperature).toBe(8);
expect(result[0].humidity).toBe(75);
expect(result[0].windSpeed).toBe(5);
expect(result[0].windFromDirection).toBe(200);
expect(result[0].precipitationAmount).toBe(2.5);
expect(result[0].precipitationProbability).toBe(70);
expect(result[0].sunrise).toBeInstanceOf(Date);
expect(result[0].sunset).toBeInstanceOf(Date);
expect(result[0].weatherType).toBe("day-cloudy");
expect(result[1].minTemperature).toBe(0);
expect(result[1].maxTemperature).toBe(6);
});
});
describe("Hourly Parsing", () => {
it("should parse hourly forecast data", async () => {
const provider = new BuienradarProvider({ locationId: 6275, type: "hourly", maxEntries: 3 });
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse()))
);
provider.initialize();
provider.start();
const result = await dataPromise;
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
expect(result[0].temperature).toBe(6);
expect(result[0].feelsLikeTemp).toBe(3);
expect(result[0].humidity).toBe(80);
expect(result[0].windSpeed).toBe(4);
expect(result[0].windFromDirection).toBe(180);
expect(result[0].precipitationAmount).toBe(0.5);
expect(result[0].precipitationProbability).toBe(60);
expect(result[0].weatherType).toBe("day-sunny");
expect(result[0].date).toBeInstanceOf(Date);
expect(result[1].weatherType).toBe("day-cloudy");
expect(result[2].weatherType).toBe("cloudy");
});
});
describe("Error Handling", () => {
it("should call error callback on invalid API response", async () => {
const provider = new BuienradarProvider({ locationId: 6275, type: "current" });
const errorPromise = new Promise((resolve) => {
provider.setCallbacks(vi.fn(), resolve);
});
server.use(
http.get(BUIENRADAR_URL, () => HttpResponse.json({ days: [] }))
);
provider.initialize();
provider.start();
const error = await errorPromise;
expect(error).toHaveProperty("message");
expect(error).toHaveProperty("translationKey");
});
});
});
@@ -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,343 @@
/**
* 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;
const currentHourUnix = Math.floor(currentData.current_weather.time / 3600) * 3600;
const currentHourIndex = currentData.hourly.time.findIndex((time) => time === currentHourUnix);
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(currentData.hourly.relativehumidity_2m[currentHourIndex]);
expect(result.humidity).not.toBe(currentData.hourly.relativehumidity_2m[0]);
});
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,554 @@
/**
* 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" };
import currentData from "../../../../../mocks/weather_owm_current.json" with { type: "json" };
import forecastData from "../../../../../mocks/weather_owm_forecast.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");
});
});
describe("API v2.5 - Current Weather (/weather endpoint)", () => {
it("should parse current weather from /weather endpoint", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/weather",
type: "current"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/weather", () => {
return HttpResponse.json(currentData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(result.temperature).toBe(-0.27);
expect(result.feelsLikeTemp).toBe(-3.9);
expect(result.humidity).toBe(54);
expect(result.windSpeed).toBe(3.09);
expect(result.windFromDirection).toBe(220);
expect(result.weatherType).toBe("cloudy-windy");
expect(result.sunrise).toBeInstanceOf(Date);
expect(result.sunset).toBeInstanceOf(Date);
});
it("should set location name from city name and country", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/weather",
type: "current"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/weather", () => {
return HttpResponse.json(currentData);
})
);
await provider.initialize();
provider.start();
await dataPromise;
expect(provider.locationName).toBe("Munich, DE");
});
});
describe("API v2.5 - Forecast (/forecast endpoint)", () => {
it("should parse /forecast endpoint into daily grouped forecast", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
});
it("should correctly aggregate min/max temperatures per day", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Day 1: temp_min values: -1.5, -1.5, -1.0, 0.5, 1.5, 1.0, 0.5, -0.5 → min=-1.5
expect(result[0].minTemperature).toBe(-1.5);
// Day 1: temp_max values: -0.5, -0.9, 0.0, 1.5, 2.5, 2.0, 1.2, 0.1 → max=2.5
expect(result[0].maxTemperature).toBe(2.5);
// Day 2: temp_min values: 0.0, 0.5, 1.5, 3.0, 4.5, 4.0, 2.5, 1.0 → min=0.0
expect(result[1].minTemperature).toBe(0.0);
// Day 2: temp_max values: 1.0, 1.5, 2.5, 4.0, 5.5, 5.0, 3.5, 2.0 → max=5.5
expect(result[1].maxTemperature).toBe(5.5);
});
it("should pick daytime weather type (8-17h)", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Day 1 daytime entries have icon "10d" → "rain"
expect(result[0].weatherType).toBe("rain");
// Day 2 daytime entries have icon "09d" → "showers"
expect(result[1].weatherType).toBe("showers");
});
it("should accumulate precipitation per day", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Day 1: two rain entries of 0.6 each = 1.2
expect(result[0].rain).toBeCloseTo(1.2);
expect(result[0].precipitationAmount).toBeCloseTo(1.2);
// Day 2: one snow entry of 0.5
expect(result[1].snow).toBeCloseTo(0.5);
expect(result[1].precipitationAmount).toBeCloseTo(0.5);
});
it("should set location name from city in forecast response", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
await dataPromise;
expect(provider.locationName).toBe("Munich, DE");
});
});
describe("API v2.5 - Hourly (/forecast endpoint with type hourly)", () => {
it("should return individual 3h entries instead of aggregating", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "hourly"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(forecastData.list.length);
});
it("should map temperature and wind from each 3h slot", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "hourly"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(result[0].temperature).toBe(forecastData.list[0].main.temp);
expect(result[0].windSpeed).toBe(forecastData.list[0].wind.speed);
expect(result[0].precipitationProbability).toBe(forecastData.list[0].pop * 100);
});
it("should include precipitation when present in a slot", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "hourly"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Entry at index 3 has rain: { "3h": 0.6 }
expect(result[3].rain).toBe(0.6);
expect(result[3].precipitationAmount).toBe(0.6);
// Entry at index 11 has snow: { "3h": 0.5 }
expect(result[11].snow).toBe(0.5);
expect(result[11].precipitationAmount).toBe(0.5);
});
});
});
@@ -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,210 @@
/**
* SMHI Weather Provider Tests
*
* Tests data parsing for current, forecast, and hourly weather types.
* SMHI provides data only for Sweden, uses metric system.
*
* Fixture: weather_smhi.json uses SNOW1gv1 format (replaced PMP3gv2 2026-03-31)
*/
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 entry at index 2 which has ptype=5 (snow) but pmedian=0.0
const rainData = JSON.parse(JSON.stringify(smhiData));
rainData.timeSeries = [rainData.timeSeries[2]];
rainData.timeSeries[0].time = 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;
// pmedian is 0.0 at this entry, so all precipitation amounts are 0
expect(result.rain).toBe(0);
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,313 @@
/**
* 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].uvIndex).toBe(1);
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].uvIndex).toBe(0);
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,267 @@
/**
* 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.precipitationAmount).toBe(0);
expect(result.precipitationUnits).toBe("mm");
expect(result.precipitationProbability).toBe(0);
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,286 @@
/**
* 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/**";
// 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");
});
});
});
@@ -0,0 +1,50 @@
const defaults = require("../../../../../js/defaults");
const WeatherObject = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherobject`);
global.moment = require("moment-timezone");
global.SunCalc = require("suncalc");
describe("WeatherObject", () => {
let originalTimeZone;
let weatherobject;
beforeAll(() => {
originalTimeZone = moment.tz.guess();
moment.tz.setDefault("Africa/Dar_es_Salaam");
weatherobject = new WeatherObject();
});
it("should return true for daytime at noon", () => {
weatherobject.date = moment("12:00", "HH:mm");
weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);
expect(weatherobject.isDayTime()).toBe(true);
});
it("should return false for daytime at midnight", () => {
weatherobject.date = moment("00:00", "HH:mm");
weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);
expect(weatherobject.isDayTime()).toBe(false);
});
it("should return sunrise as the next sunaction", () => {
weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);
let midnight = moment("00:00", "HH:mm");
expect(weatherobject.nextSunAction(midnight)).toBe("sunrise");
});
it("should return sunset as the next sunaction", () => {
weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);
let noon = moment(weatherobject.sunrise).hour(14);
expect(weatherobject.nextSunAction(noon)).toBe("sunset");
});
it("should return an already defined feelsLike info", () => {
weatherobject.feelsLikeTemp = "feelsLikeTempValue";
expect(weatherobject.feelsLike()).toBe("feelsLikeTempValue");
});
afterAll(() => {
moment.tz.setDefault(originalTimeZone);
});
});
@@ -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");
});
});
});
});
@@ -0,0 +1,163 @@
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", () => {
it("should convert temp correctly from Celsius to Celsius", () => {
expect(Math.round(WeatherUtils.convertTemp(10, "metric"))).toBe(10);
});
it("should convert temp correctly from Celsius to Fahrenheit", () => {
expect(Math.round(WeatherUtils.convertTemp(10, "imperial"))).toBe(50);
});
it("should convert temp correctly from Fahrenheit to Celsius", () => {
expect(Math.round(WeatherUtils.convertTempToMetric(10))).toBe(-12);
});
});
describe("windspeed conversion to beaufort", () => {
it("should convert windspeed correctly from mps to beaufort", () => {
expect(Math.round(WeatherUtils.convertWind(5, "beaufort"))).toBe(3);
expect(Math.round(WeatherUtils.convertWind(300, "beaufort"))).toBe(12);
});
it("should convert windspeed correctly from mps to mps", () => {
expect(WeatherUtils.convertWind(11.75, "FOOBAR")).toBe(11.75);
});
it("should convert windspeed correctly from mps to kmh", () => {
expect(Math.round(WeatherUtils.convertWind(11.75, "kmh"))).toBe(42);
});
it("should convert windspeed correctly from mps to knots", () => {
expect(Math.round(WeatherUtils.convertWind(10, "knots"))).toBe(19);
});
it("should convert windspeed correctly from mph to mps", () => {
expect(Math.round(WeatherUtils.convertWindToMetric(93.951324266285))).toBe(42);
});
it("should convert windspeed correctly from kmh to mps", () => {
expect(Math.round(WeatherUtils.convertWindToMs(151.2))).toBe(42);
});
});
describe("wind direction conversion", () => {
it("should convert wind direction correctly from cardinal to value", () => {
expect(WeatherUtils.convertWindDirection("SSE")).toBe(157);
expect(WeatherUtils.convertWindDirection("XXX")).toBeNull();
});
});
describe("feelsLike calculation", () => {
it("should return a calculated feelsLike info (negative value)", () => {
expect(WeatherUtils.calculateFeelsLike(0, 20, 40)).toBe(-9.397005931555448);
});
it("should return a calculated feelsLike info (positive value)", () => {
expect(WeatherUtils.calculateFeelsLike(30, 0, 60)).toBe(32.832032277777756);
});
});
describe("precipitationUnit conversion", () => {
it("should keep value and unit if outputUnit is undefined", () => {
const values = [1, 2];
const units = ["mm", "cm"];
for (let i = 0; i < values.length; i++) {
const result = weather.convertPrecipitationUnit(values[i], units[i], undefined);
expect(result).toBe(`${values[i].toFixed(2)} ${units[i]}`);
}
});
it("should keep value and unit if outputUnit is metric", () => {
const values = [1, 2];
const units = ["mm", "cm"];
for (let i = 0; i < values.length; i++) {
const result = weather.convertPrecipitationUnit(values[i], units[i], "metric");
expect(result).toBe(`${values[i].toFixed(2)} ${units[i]}`);
}
});
it("should use mm unit if input unit is undefined", () => {
const values = [1, 2];
for (let i = 0; i < values.length; i++) {
const result = weather.convertPrecipitationUnit(values[i], undefined, "metric");
expect(result).toBe(`${values[i].toFixed(2)} mm`);
}
});
it("should convert value and unit if outputUnit is imperial", () => {
const values = [1, 2];
const units = ["mm", "cm"];
const expectedValues = [0.04, 0.79];
for (let i = 0; i < values.length; i++) {
const result = weather.convertPrecipitationUnit(values[i], units[i], "imperial");
expect(result).toBe(`${expectedValues[i]} in`);
}
});
it("should round percentage values regardless of output units", () => {
const values = [0.1, 2.22, 9.999];
const output = [undefined, "imperial", "metric"];
const result = ["0 %", "2 %", "10 %"];
for (let i = 0; i < values.length; i++) {
expect(weather.convertPrecipitationUnit(values[i], "%", output[i])).toBe(result[i]);
}
});
});
describe("convertWeatherObjectToImperial", () => {
it("should return null for null or empty input", () => {
expect(WeatherUtils.convertWeatherObjectToImperial(null)).toBeNull();
expect(WeatherUtils.convertWeatherObjectToImperial({})).toBeNull();
});
it("should convert 0°C correctly to 32°F", () => {
const result = WeatherUtils.convertWeatherObjectToImperial({ temperature: 0 });
expect(result.temperature).toBe(32);
});
it("should convert all temperature fields", () => {
const result = WeatherUtils.convertWeatherObjectToImperial({
temperature: 0,
feelsLikeTemp: 0,
minTemperature: -10,
maxTemperature: 10
});
expect(result.temperature).toBe(32);
expect(result.feelsLikeTemp).toBe(32);
expect(result.minTemperature).toBe(14);
expect(result.maxTemperature).toBe(50);
});
it("should convert windSpeed correctly", () => {
const result = WeatherUtils.convertWeatherObjectToImperial({ windSpeed: 10 });
expect(result.windSpeed).toBeCloseTo(22.369, 2);
});
it("should convert precipitationAmount correctly", () => {
const result = WeatherUtils.convertWeatherObjectToImperial({ precipitationAmount: 25.4, precipitationUnits: "mm" });
expect(result.precipitationAmount).toBeCloseTo(1, 5);
});
it("should not modify properties that are not present", () => {
const result = WeatherUtils.convertWeatherObjectToImperial({ temperature: 20 });
expect("windSpeed" in result).toBe(false);
expect("feelsLikeTemp" in result).toBe(false);
});
it("should not mutate the original object", () => {
const input = { temperature: 20 };
WeatherUtils.convertWeatherObjectToImperial(input);
expect(input.temperature).toBe(20);
});
});
});