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
+15
View File
@@ -0,0 +1,15 @@
const deprecated = require("../../../js/deprecated");
describe("Deprecated", () => {
it("should be an object", () => {
expect(typeof deprecated).toBe("object");
});
it("should contain clock array with deprecated options as strings", () => {
expect(Array.isArray(["deprecated.clock"])).toBe(true);
for (let option of deprecated.configs) {
expect(typeof option).toBe("string");
}
expect(deprecated.clock).toEqual(expect.arrayContaining(["secondsColor"]));
});
});
+299
View File
@@ -0,0 +1,299 @@
const path = require("node:path");
const { pathToFileURL } = require("node:url");
describe("File js/module (cloneObject)", () => {
describe("Test function cloneObject", () => {
let clone;
let Module;
let originalWindow;
let originalLog;
let originalConfig;
let originalMM;
let originalTranslator;
let originalNunjucks;
beforeAll(async () => {
originalWindow = global.window;
originalLog = global.Log;
originalConfig = global.config;
originalMM = global.MM;
originalTranslator = global.Translator;
originalNunjucks = global.nunjucks;
global.window = { mmVersion: "2.0.0" };
global.Log = { log: () => {}, info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
global.config = { language: "en" };
global.MM = {
hideModule: () => {},
showModule: () => {},
sendNotification: () => {},
updateDom: () => {}
};
global.Translator = {
load: () => Promise.resolve(),
translate: () => ""
};
global.nunjucks = {
Environment () {
this.addFilter = () => {};
this.renderString = () => "";
this.render = (_template, _data, callback) => callback(null, "");
},
WebLoader () {},
runtime: {
markSafe: (str) => str
}
};
const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "..", "js", "module.js")).href;
const loaded = await import(`${modulePath}?test=${Date.now()}`);
clone = loaded.cloneObject;
Module = loaded.Module;
});
afterAll(() => {
global.window = originalWindow;
global.Log = originalLog;
global.config = originalConfig;
global.MM = originalMM;
global.Translator = originalTranslator;
global.nunjucks = originalNunjucks;
});
it("should clone object", () => {
const expected = { name: "Rodrigo", web: "https://rodrigoramirez.com", project: "MagicMirror" };
const obj = clone(expected);
expect(obj).toEqual(expected);
expect(expected === obj).toBe(false);
});
it("should clone array", () => {
const expected = [1, null, undefined, "TEST"];
const obj = clone(expected);
expect(obj).toEqual(expected);
expect(expected === obj).toBe(false);
});
it("should clone number", () => {
let expected = 1;
let obj = clone(expected);
expect(obj).toBe(expected);
expected = 1.23;
obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone string", () => {
const expected = "Perfect stranger";
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone regex", () => {
const expected = /.*Magic/;
const obj = clone(expected);
expect(obj).toEqual(expected);
expect(expected === obj).toBe(false);
});
it("should clone date", () => {
const expected = new Date("2026-05-11T20:00:00.000Z");
const obj = clone(expected);
expect(obj).toEqual(expected);
expect(expected === obj).toBe(false);
});
it("should return URL by reference", () => {
const expected = new URL("https://magicmirror.builders/path?q=1");
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should return map by reference", () => {
const mapValue = { nested: [1, 2, 3] };
const expected = new Map([["module", mapValue]]);
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should return set by reference", () => {
const setValue = { nested: true };
const expected = new Set([setValue]);
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should return class instances by reference", () => {
class ModuleDefaults {
constructor () {
this.enabled = true;
}
}
const expected = new ModuleDefaults();
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone undefined", () => {
const expected = undefined;
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone null", () => {
const expected = null;
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone nested object", () => {
const expected = {
name: "fewieden",
link: "https://github.com/fewieden",
versions: ["2.0", "2.1", "2.2"],
answerForAllQuestions: 42,
properties: {
items: [{ foo: "bar" }, { lorem: "ipsum" }],
invalid: undefined,
nothing: null
}
};
const obj = clone(expected);
expect(obj).toEqual(expected);
expect(expected === obj).toBe(false);
expect(expected.versions === obj.versions).toBe(false);
expect(expected.properties === obj.properties).toBe(false);
expect(expected.properties.items === obj.properties.items).toBe(false);
expect(expected.properties.items[0] === obj.properties.items[0]).toBe(false);
expect(expected.properties.items[1] === obj.properties.items[1]).toBe(false);
});
describe("Test Module.create", () => {
let info;
beforeEach(() => {
info = global.Log.info;
});
afterEach(() => {
global.Log.info = info;
Module.definitions = {};
});
it("should create module instance with dynamic class name", () => {
const moduleName = "MMM-TestModule";
Module.register(moduleName, {
defaults: {}
});
const moduleInstance = Module.create(moduleName);
expect(moduleInstance.constructor.name).toBe(moduleName);
});
it("should use fallback class name for empty module name", () => {
const moduleName = "";
Module.register(moduleName, {
defaults: {}
});
const moduleInstance = Module.create(moduleName);
expect(moduleInstance.constructor.name).toBe("AnonymousModule");
});
it("should not throw when init is not a function", () => {
const moduleName = "MMM-TestModuleNoInitFunction";
Module.register(moduleName, {
init: null,
defaults: {}
});
expect(() => Module.create(moduleName)).not.toThrow();
});
it("should support lifecycle super call pattern", () => {
const moduleName = "MMM-TestSuperCall";
let loggedMessage;
global.Log.info = (message) => {
loggedMessage = message;
};
Module.register(moduleName, {
defaults: {},
start () {
this.didStart = true;
Module.prototype.start.call(this);
}
});
const moduleInstance = Module.create(moduleName);
moduleInstance.name = moduleName;
moduleInstance.start();
expect(moduleInstance.didStart).toBe(true);
expect(loggedMessage).toBe(`Starting module: ${moduleName}`);
});
it("should set config when defaults are undefined", () => {
const moduleName = "MMM-TestNoDefaults";
Module.register(moduleName, {});
const moduleInstance = Module.create(moduleName);
moduleInstance.setConfig({ foo: "bar" }, false);
expect(moduleInstance.config).toEqual({ foo: "bar" });
moduleInstance.setConfig({ nested: { value: 1 } }, true);
expect(moduleInstance.config).toEqual({ nested: { value: 1 } });
});
it("should initialize lifecycle fields in setData", () => {
const moduleName = "MMM-TestSetData";
Module.register(moduleName, {
defaults: { fromDefaults: true }
});
const moduleInstance = Module.create(moduleName);
moduleInstance.setData({
name: moduleName,
identifier: "module_1",
config: { fromConfig: true },
configDeepMerge: false
});
expect(moduleInstance.name).toBe(moduleName);
expect(moduleInstance.identifier).toBe("module_1");
expect(moduleInstance.hidden).toBe(false);
expect(moduleInstance.hasAnimateIn).toBe(false);
expect(moduleInstance.hasAnimateOut).toBe(false);
expect(moduleInstance.config).toEqual({ fromDefaults: true, fromConfig: true });
});
it("should not share defaults object across module instances", () => {
const moduleName = "MMM-TestDefaultsIsolation";
Module.register(moduleName, {
defaults: {
nested: { value: 1 },
list: [1]
}
});
const firstModuleInstance = Module.create(moduleName);
const secondModuleInstance = Module.create(moduleName);
firstModuleInstance.defaults.nested.value = 42;
firstModuleInstance.defaults.list.push(2);
expect(secondModuleInstance.defaults).toEqual({
nested: { value: 1 },
list: [1]
});
});
});
});
});
@@ -0,0 +1,7 @@
const SystemInformation = require("../../../js/systeminformation");
describe("SystemInformation", () => {
it("should output system information", async () => {
await expect(SystemInformation()).resolves.toContain("platform: linux");
});
});
+230
View File
@@ -0,0 +1,230 @@
const fs = require("node:fs");
const path = require("node:path");
const helmet = require("helmet");
const { JSDOM } = require("jsdom");
const express = require("express");
/**
* Helper function to create a fresh Translator instance with DOM environment.
* @returns {object} Object containing window and Translator
*/
function createTranslationTestEnvironment () {
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "..", "js", "translator.js"), "utf-8");
const dom = new JSDOM("", { url: "http://localhost:3001", runScripts: "outside-only" });
dom.window.Log = { log: vi.fn(), error: vi.fn() };
dom.window.fetch = fetch;
dom.window.eval(translatorJs);
return { window: dom.window, Translator: dom.window.Translator };
}
describe("Translator", () => {
let server;
const sockets = new Set();
const translationTestData = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"), "utf8"));
beforeAll(() => {
const app = express();
app.use(helmet());
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
next();
});
app.use("/translations", express.static(path.join(__dirname, "..", "..", "..", "tests", "mocks")));
server = app.listen(3001);
server.on("connection", (socket) => {
sockets.add(socket);
});
});
afterAll(async () => {
for (const socket of sockets) {
socket.destroy();
sockets.delete(socket);
}
await server.close();
});
describe("translate", () => {
const translations = {
"MMM-Module": {
Hello: "Hallo",
"Hello {username}": "Hallo {username}"
}
};
const coreTranslations = {
Hello: "XXX",
"Hello {username}": "XXX",
FOO: "Foo",
"BAR {something}": "Bar {something}"
};
const translationsFallback = {
"MMM-Module": {
Hello: "XXX",
"Hello {username}": "XXX",
FOO: "XXX",
"BAR {something}": "XXX",
"A key": "A translation"
}
};
const coreTranslationsFallback = {
FOO: "XXX",
"BAR {something}": "XXX",
Hello: "XXX",
"Hello {username}": "XXX",
"A key": "XXX",
Fallback: "core fallback"
};
/**
* @param {object} Translator the global Translator object
*/
const setTranslations = (Translator) => {
Translator.translations = translations;
Translator.coreTranslations = coreTranslations;
Translator.translationsFallback = translationsFallback;
Translator.coreTranslationsFallback = coreTranslationsFallback;
};
it("should return custom module translation", () => {
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "Hello");
expect(translation).toBe("Hallo");
translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}", { username: "fewieden" });
expect(translation).toBe("Hallo fewieden");
});
it("should return core translation", () => {
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "FOO");
expect(translation).toBe("Foo");
translation = Translator.translate({ name: "MMM-Module" }, "BAR {something}", { something: "Lorem Ipsum" });
expect(translation).toBe("Bar Lorem Ipsum");
});
it("should return custom module translation fallback", () => {
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "A key");
expect(translation).toBe("A translation");
});
it("should return core translation fallback", () => {
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Fallback");
expect(translation).toBe("core fallback");
});
it("should return translation with placeholder for missing variables", () => {
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}");
expect(translation).toBe("Hallo {username}");
});
it("should return key if no translation was found", () => {
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "MISSING");
expect(translation).toBe("MISSING");
});
});
describe("load", () => {
const mmm = {
name: "TranslationTest",
file (file) {
return `http://localhost:3001/translations/${file}`;
}
};
it("should load translations", async () => {
const { Translator } = createTranslationTestEnvironment();
const file = "translation_test.json";
await Translator.load(mmm, file, false);
const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8"));
expect(Translator.translations[mmm.name]).toEqual(json);
});
it("should load translation fallbacks", async () => {
const { Translator } = createTranslationTestEnvironment();
const file = "translation_test.json";
await Translator.load(mmm, file, true);
const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8"));
expect(Translator.translationsFallback[mmm.name]).toEqual(json);
});
it("should not load translations, if module fallback exists", async () => {
const { Translator } = createTranslationTestEnvironment();
const file = "translation_test.json";
Translator.translationsFallback[mmm.name] = {
Hello: "Hallo"
};
await Translator.load(mmm, file, false);
expect(Translator.translations[mmm.name]).toBeUndefined();
expect(Translator.translationsFallback[mmm.name]).toEqual({
Hello: "Hallo"
});
});
});
describe("loadCoreTranslations", () => {
it("should load core translations and fallback", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslations("en");
const en = translationTestData;
expect(Translator.coreTranslations).toEqual(en);
expect(Translator.coreTranslationsFallback).toEqual(en);
});
it("should load core fallback if language cannot be found", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslations("MISSINGLANG");
const en = translationTestData;
expect(Translator.coreTranslations).toEqual({});
expect(Translator.coreTranslationsFallback).toEqual(en);
});
});
describe("loadCoreTranslationsFallback", () => {
it("should load core translations fallback", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslationsFallback();
const en = translationTestData;
expect(Translator.coreTranslationsFallback).toEqual(en);
});
it("should load core fallback if language cannot be found", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = {};
await Translator.loadCoreTranslations();
expect(Translator.coreTranslationsFallback).toEqual({});
});
});
});
+83
View File
@@ -0,0 +1,83 @@
const fs = require("node:fs");
const Log = require("../../../js/logger");
const { checkConfigFile, ConfigError } = require("../../../js/utils");
const createConfigObject = (modules) => ({
configFilename: "config.js",
configContentFull: "module.exports = { modules: [] };",
fullConf: { modules }
});
const runCheck = (modules) => {
checkConfigFile(createConfigObject(modules));
};
const expectExitForModules = (modules) => {
vi.spyOn(process, "exit").mockImplementation(() => {
throw new ConfigError("");
});
expect(() => {
runCheck(modules);
}).toThrow(ConfigError);
};
describe("utils", () => {
let originalReadFileSync;
beforeEach(() => {
originalReadFileSync = fs.readFileSync;
vi.spyOn(fs, "readFileSync").mockImplementation((fileName, ...args) => {
if (fileName === "index.html") {
return "<div class=\"region top_bar\"></div>\n<div class=\"region lower_third\"></div>";
}
return originalReadFileSync.call(fs, fileName, ...args);
});
vi.spyOn(fs, "writeFileSync").mockImplementation(() => {});
vi.spyOn(Log, "info").mockImplementation(() => {});
vi.spyOn(Log, "warn").mockImplementation(() => {});
vi.spyOn(Log, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("accepts valid module entries", () => {
expect(() => {
runCheck([
{ module: "clock", position: "top_bar" },
{ module: "newsfeed" }
]);
}).not.toThrow();
expect(Log.error).not.toHaveBeenCalled();
});
it("exits when modules is not an array", () => {
expectExitForModules("not-an-array");
expect(Log.error).toHaveBeenCalledWith("This module configuration contains errors:\nmodules must be an array");
});
it("exits when module field is missing or not a string", () => {
expectExitForModules([{ module: 123, position: "top_bar" }]);
expect(Log.error).toHaveBeenCalled();
expect(Log.error.mock.calls[0][0]).toContain("module: must be a string");
});
it("warns for unknown positions without exiting", () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new ConfigError("");
});
expect(() => {
runCheck([{ module: "clock", position: "made_up_region" }]);
}).not.toThrow();
expect(exitSpy).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalled();
expect(Log.warn.mock.calls[0][0]).toContain("uses unknown position");
});
});
@@ -0,0 +1,100 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Updatenotification > MagicMirror on develop > returns status information 1`] = `
{
"behind": 5,
"current": "develop",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/develop",
}
`;
exports[`Updatenotification > MagicMirror on develop > returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "develop",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/develop",
}
`;
exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information 1`] = `
{
"behind": 0,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information early if isBehindInStatus 1`] = `
{
"behind": 0,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > custom module > returns status information without hash 1`] = `
{
"behind": 7,
"current": "master",
"hash": "",
"isBehindInStatus": false,
"module": "MMM-Fuel",
"tracking": "origin/master",
}
`;
+71
View File
@@ -0,0 +1,71 @@
const path = require("node:path");
const { pathToFileURL } = require("node:url");
describe("Test function cmpVersions in js/module.js", () => {
let cmp;
let originalWindow;
let originalLog;
let originalConfig;
let originalMM;
let originalTranslator;
let originalNunjucks;
beforeAll(async () => {
originalWindow = global.window;
originalLog = global.Log;
originalConfig = global.config;
originalMM = global.MM;
originalTranslator = global.Translator;
originalNunjucks = global.nunjucks;
global.window = { mmVersion: "2.0.0" };
global.Log = { log: () => {}, info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
global.config = { language: "en" };
global.MM = {
hideModule: () => {},
showModule: () => {},
sendNotification: () => {},
updateDom: () => {}
};
global.Translator = {
load: () => Promise.resolve(),
translate: () => ""
};
global.nunjucks = {
Environment () {
this.addFilter = () => {};
this.renderString = () => "";
this.render = (_template, _data, callback) => callback(null, "");
},
WebLoader () {},
runtime: {
markSafe: (str) => str
}
};
const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "..", "js", "module.js")).href;
const loaded = await import(`${modulePath}?test=${Date.now()}`);
cmp = loaded.cmpVersions;
});
afterAll(() => {
global.window = originalWindow;
global.Log = originalLog;
global.config = originalConfig;
global.MM = originalMM;
global.Translator = originalTranslator;
global.nunjucks = originalNunjucks;
});
it("should return -1 when comparing 2.1 to 2.2", () => {
expect(cmp("2.1", "2.2")).toBe(-1);
});
it("should be return 0 when comparing 2.2 to 2.2", () => {
expect(cmp("2.2", "2.2")).toBe(0);
});
it("should be return 1 when comparing 1.1 to 1.0", () => {
expect(cmp("1.1", "1.0")).toBe(1);
});
});
@@ -0,0 +1,72 @@
const Log = require("logger");
const { applyElectronSwitches } = require("../../../js/electron_helper");
describe("electron switches", () => {
let commandLine;
beforeEach(() => {
commandLine = {
appendSwitch: vi.fn()
};
vi.spyOn(Log, "error").mockImplementation(() => {});
});
it("does nothing when electronSwitches is undefined", () => {
applyElectronSwitches(commandLine, undefined);
expect(commandLine.appendSwitch).not.toHaveBeenCalled();
expect(Log.error).not.toHaveBeenCalled();
});
it("applies string entries as switches without values", () => {
applyElectronSwitches(commandLine, ["no-sandbox", "disable-http-cache"]);
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(2);
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "no-sandbox");
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "disable-http-cache");
expect(Log.error).not.toHaveBeenCalled();
});
it("applies object entries as switches with values", () => {
applyElectronSwitches(commandLine, [
{ "js-flags": "--max-old-space-size=8192" },
{ "password-store": "basic" }
]);
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(2);
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "js-flags", "--max-old-space-size=8192");
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "password-store", "basic");
expect(Log.error).not.toHaveBeenCalled();
});
it("allows one object entry to define multiple switches with values", () => {
applyElectronSwitches(commandLine, [
"no-sandbox",
{
"js-flags": "--max-old-space-size=8192",
"password-store": "basic"
}
]);
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(3);
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "no-sandbox");
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "js-flags", "--max-old-space-size=8192");
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(3, "password-store", "basic");
expect(Log.error).not.toHaveBeenCalled();
});
it("logs an error for invalid entries", () => {
applyElectronSwitches(commandLine, ["no-sandbox", ["js-flags", "--max-old-space-size=8192"], null]);
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(1);
expect(commandLine.appendSwitch).toHaveBeenCalledWith("no-sandbox");
expect(Log.error).toHaveBeenCalledTimes(2);
});
it("logs an error when electronSwitches is not an array", () => {
applyElectronSwitches(commandLine, { "js-flags": "--max-old-space-size=8192" });
expect(commandLine.appendSwitch).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining("electronSwitches must be an array of strings or objects"));
});
});
+579
View File
@@ -0,0 +1,579 @@
const { http, HttpResponse } = require("msw");
const { setupServer } = require("msw/node");
const HTTPFetcher = require("#http_fetcher");
const TEST_URL = "http://test.example.com/data";
let server;
let fetcher;
beforeAll(() => {
server = setupServer();
server.listen({ onUnhandledRequest: "error" });
});
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
if (fetcher) {
fetcher.clearTimer();
fetcher = null;
}
});
describe("HTTPFetcher", () => {
describe("Basic fetch operations", () => {
it("should emit response event on successful fetch", async () => {
const responseData = "test data";
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text(responseData);
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", (response) => {
resolve(response);
});
});
fetcher.startPeriodicFetch();
const response = await responsePromise;
expect(response.ok).toBe(true);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe(responseData);
});
it("should emit error event on network failure", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.error();
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo).toHaveProperty("errorType", "NETWORK_ERROR");
expect(errorInfo).toHaveProperty("translationKey", "MODULE_ERROR_NO_CONNECTION");
expect(errorInfo).toHaveProperty("url", TEST_URL);
});
it("should emit error event on timeout", async () => {
server.use(
http.get(TEST_URL, async () => {
// Simulate a slow server that never responds
await new Promise((resolve) => setTimeout(resolve, 60000));
return HttpResponse.text("too late");
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000, timeout: 100 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
expect(errorInfo.message).toContain("timeout");
expect(errorInfo.message).toContain("100ms");
});
});
describe("HTTPFetcher - HTTP status code handling", () => {
describe("304 Not Modified", () => {
it("should emit response event for 304 and not emit error", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 304 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
fetcher.serverErrorCount = 2;
fetcher.networkErrorCount = 3;
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
const errorSpy = vi.fn();
fetcher.on("error", errorSpy);
fetcher.startPeriodicFetch();
const response = await responsePromise;
expect(response.status).toBe(304);
expect(errorSpy).not.toHaveBeenCalled();
expect(fetcher.serverErrorCount).toBe(0);
expect(fetcher.networkErrorCount).toBe(0);
});
});
describe("401/403 errors (Auth failures)", () => {
it("should emit error with AUTH_FAILURE for 401", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 401 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(401);
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
expect(errorInfo.translationKey).toBe("MODULE_ERROR_UNAUTHORIZED");
});
it("should emit error with AUTH_FAILURE for 403", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 403 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(403);
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
});
});
describe("429 errors (Rate limiting)", () => {
it("should emit error with RATE_LIMITED for 429", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, {
status: 429,
headers: { "Retry-After": "120" }
});
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(429);
expect(errorInfo.errorType).toBe("RATE_LIMITED");
expect(errorInfo.retryAfter).toBeGreaterThan(0);
});
it("should parse Retry-After header in seconds", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, {
status: 429,
headers: { "Retry-After": "300" }
});
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
// 300 seconds = 300000 ms
expect(errorInfo.retryAfter).toBe(300000);
});
});
describe("5xx errors (Server errors)", () => {
it("should emit error with SERVER_ERROR for 500", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 500 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(500);
expect(errorInfo.errorType).toBe("SERVER_ERROR");
});
it("should emit error with SERVER_ERROR for 503", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 503 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(503);
expect(errorInfo.errorType).toBe("SERVER_ERROR");
});
});
describe("4xx errors (Client errors)", () => {
it("should emit error with CLIENT_ERROR for 404", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 404 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(404);
expect(errorInfo.errorType).toBe("CLIENT_ERROR");
});
});
});
});
describe("HTTPFetcher - Authentication", () => {
it("should include Basic auth header when configured", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
auth: {
method: "basic",
user: "testuser",
pass: "testpass"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
const expectedAuth = `Basic ${Buffer.from("testuser:testpass").toString("base64")}`;
expect(receivedHeaders.authorization).toBe(expectedAuth);
});
it("should include Bearer auth header when configured", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
auth: {
method: "bearer",
pass: "my-token-123"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
expect(receivedHeaders.authorization).toBe("Bearer my-token-123");
});
});
describe("Custom headers", () => {
it("should include custom headers in request", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
headers: {
"X-Custom-Header": "custom-value",
Accept: "application/json"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
expect(receivedHeaders["x-custom-header"]).toBe("custom-value");
expect(receivedHeaders.accept).toBe("application/json");
});
});
describe("Timer management", () => {
it("should not set timer in test mode", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
// Timer should NOT be set in test mode (mmTestMode=true)
expect(fetcher.reloadTimer).toBeNull();
});
it("should clear timer when clearTimer is called", () => {
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
// Manually set a timer to test clearing
fetcher.reloadTimer = setTimeout(() => {}, 10000);
expect(fetcher.reloadTimer).not.toBeNull();
fetcher.clearTimer();
expect(fetcher.reloadTimer).toBeNull();
});
});
describe("fetch() method", () => {
it("should emit response event when called", async () => {
const responseData = "direct fetch data";
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text(responseData);
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
await fetcher.fetch();
const response = await responsePromise;
expect(response.ok).toBe(true);
const text = await response.text();
expect(text).toBe(responseData);
});
it("should emit error event on network error", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.error();
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", resolve);
});
await fetcher.fetch();
const errorInfo = await errorPromise;
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
});
});
describe("selfSignedCert dispatcher", () => {
const { Agent } = require("undici");
it("should set rejectUnauthorized=false when selfSignedCert is true", () => {
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
selfSignedCert: true
});
const options = fetcher.getRequestOptions();
expect(options.dispatcher).toBeInstanceOf(Agent);
const agentOptionsSymbol = Object.getOwnPropertySymbols(options.dispatcher).find((s) => s.description === "options");
const dispatcherOptions = options.dispatcher[agentOptionsSymbol];
expect(dispatcherOptions.connect.rejectUnauthorized).toBe(false);
});
it("should not set a dispatcher when selfSignedCert is false", () => {
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
selfSignedCert: false
});
const options = fetcher.getRequestOptions();
expect(options.dispatcher).toBeUndefined();
});
});
describe("Retry exhaustion fallback", () => {
it("should fall back to reloadInterval after network retries exhausted", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.error();
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
const errors = [];
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
// Trigger maxRetries + 1 fetches to reach exhaustion
for (let i = 0; i < 4; i++) {
await fetcher.fetch();
}
// First retries should use backoff (< reloadInterval)
expect(errors[0].retryAfter).toBe(15000);
expect(errors[1].retryAfter).toBe(30000);
// Third retry hits maxRetries, should fall back to reloadInterval
expect(errors[2].retryAfter).toBe(300000);
// Subsequent errors stay at reloadInterval
expect(errors[3].retryAfter).toBe(300000);
});
it("should fall back to reloadInterval after server error retries exhausted", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 503 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
const errors = [];
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
for (let i = 0; i < 4; i++) {
await fetcher.fetch();
}
// First retries should use backoff (< reloadInterval)
expect(errors[0].retryAfter).toBe(15000);
expect(errors[1].retryAfter).toBe(30000);
// Third retry hits maxRetries, should fall back to reloadInterval
expect(errors[2].retryAfter).toBe(300000);
// Subsequent errors stay at reloadInterval
expect(errors[3].retryAfter).toBe(300000);
});
it("should reset network error count on success", async () => {
let requestCount = 0;
server.use(
http.get(TEST_URL, () => {
requestCount++;
if (requestCount <= 2) return HttpResponse.error();
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
const errors = [];
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
// Two failures with backoff
await fetcher.fetch();
await fetcher.fetch();
expect(errors).toHaveLength(2);
expect(errors[0].retryAfter).toBe(15000);
expect(errors[1].retryAfter).toBe(30000);
// Success resets counter
await fetcher.fetch();
expect(fetcher.networkErrorCount).toBe(0);
});
});
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import { ipAccessControl, socketIpAccessControl } from "../../../js/ip_access_control";
/**
* Creates a minimal Express-like response mock used by the middleware tests.
* @returns {{ header: ReturnType<typeof vi.fn>, status: ReturnType<typeof vi.fn>, send: ReturnType<typeof vi.fn> }} Mock response object.
*/
function createResponseMock () {
return {
header: vi.fn(),
status: vi.fn(function () {
return this;
}),
send: vi.fn()
};
}
describe("ip_access_control", () => {
describe("ipAccessControl", () => {
it("trusts first X-Forwarded-For entry when direct peer is loopback", () => {
const middleware = ipAccessControl(["203.0.113.10"]);
const req = {
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" }
};
const res = createResponseMock();
const next = vi.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
it("ignores X-Forwarded-For when direct peer is not loopback", () => {
const middleware = ipAccessControl(["203.0.113.10"]);
const req = {
socket: { remoteAddress: "198.51.100.7" },
headers: { "x-forwarded-for": "203.0.113.10" }
};
const res = createResponseMock();
const next = vi.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe("socketIpAccessControl", () => {
it("accepts socket handshake using forwarded client IP when direct peer is loopback", () => {
const allowRequest = socketIpAccessControl(["203.0.113.10"]);
const req = {
socket: { remoteAddress: "::1" },
headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" }
};
const callback = vi.fn();
allowRequest(req, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it("rejects socket handshake when only forwarded IP matches whitelist", () => {
const allowRequest = socketIpAccessControl(["203.0.113.10"]);
const req = {
socket: { remoteAddress: "198.51.100.7" },
headers: { "x-forwarded-for": "203.0.113.10" }
};
const callback = vi.fn();
allowRequest(req, callback);
expect(callback).toHaveBeenCalledWith("This device is not allowed to access your mirror.", false);
});
});
});
@@ -0,0 +1,331 @@
// Tests use vi.spyOn on shared module objects (dns, undici).
// vi.spyOn modifies the object property directly on the cached module instance, so it
// is intercepted by server_functions.js regardless of the Module.prototype.require override
// in vitest-setup.js. restoreAllMocks:true auto-restores spies, but may reuse the same
// spy instance — mockClear() is called explicitly in beforeEach to reset call history.
const dns = require("node:dns");
const undici = require("undici");
const { cors, getUserAgent, replaceSecretPlaceholder } = require("#server_functions");
describe("server_functions tests", () => {
describe("The replaceSecretPlaceholder method with cors=allowWhitelist", () => {
beforeEach(() => {
global.config = { cors: "allowWhitelist" };
});
it("Calls string without secret placeholder", () => {
const teststring = "test string without secret placeholder";
const result = replaceSecretPlaceholder(teststring);
expect(result).toBe(teststring);
});
it("Calls string with 2 secret placeholders", () => {
const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**";
process.env.SECRET_ONE = "secret1";
process.env.SECRET_TWO = "secret2";
const resultstring = `test string with secret1=${process.env.SECRET_ONE} and secret2=${process.env.SECRET_TWO}`;
const result = replaceSecretPlaceholder(teststring);
expect(result).toBe(resultstring);
});
});
describe("The replaceSecretPlaceholder method with cors=allowAll", () => {
beforeEach(() => {
global.config = { cors: "allowAll" };
});
it("Calls string without secret placeholder", () => {
const teststring = "test string without secret placeholder";
const result = replaceSecretPlaceholder(teststring);
expect(result).toBe(teststring);
});
it("Calls string with 2 secret placeholders", () => {
const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**";
const result = replaceSecretPlaceholder(teststring);
expect(result).toBe(teststring);
});
});
describe("The replaceSecretPlaceholder method with an allowedSecrets set", () => {
beforeEach(() => {
global.config = { cors: "allowWhitelist" };
process.env.SECRET_ALLOWED = "allowed-value";
process.env.SECRET_DENIED = "denied-value";
});
it("Restores only allowed secrets and keeps denied placeholders untouched", () => {
const teststring = "allowed=**SECRET_ALLOWED** denied=**SECRET_DENIED**";
const result = replaceSecretPlaceholder(teststring, new Set(["SECRET_ALLOWED"]));
expect(result).toBe("allowed=allowed-value denied=**SECRET_DENIED**");
expect(result).not.toContain("denied-value");
});
it("Does not restore any placeholder when the set is empty", () => {
const teststring = "value=**SECRET_ALLOWED**";
const result = replaceSecretPlaceholder(teststring, new Set());
expect(result).toBe(teststring);
});
it("Falls back to the placeholder if the allowed secret doesn't exist in environment", () => {
const teststring = "value=**SECRET_MISSING**";
const result = replaceSecretPlaceholder(teststring, new Set(["SECRET_MISSING"]));
expect(result).toBe(teststring);
});
});
describe("The cors method", () => {
let fetchSpy;
let fetchResponseHeadersGet;
let fetchResponseArrayBuffer;
let corsResponse;
let request;
beforeEach(() => {
global.config = { cors: "allowAll" };
fetchResponseHeadersGet = vi.fn(() => {});
fetchResponseArrayBuffer = vi.fn(() => {});
// Mock DNS to return a public IP (SSRF check must pass for these tests)
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
// vi.spyOn may return the same spy instance across tests when restoreAllMocks
// restores-but-reuses; mockClear() explicitly resets call history each time.
fetchSpy = vi.spyOn(undici, "fetch");
fetchSpy.mockClear();
fetchSpy.mockImplementation(() => Promise.resolve({
headers: { get: fetchResponseHeadersGet },
arrayBuffer: fetchResponseArrayBuffer,
ok: true
}));
corsResponse = {
set: vi.fn(() => {}),
send: vi.fn(() => {}),
status: vi.fn(function (code) {
this.statusCode = code;
return this;
}),
json: vi.fn(() => {})
};
request = {
url: "/cors?url=http://www.test.com"
};
});
it("Calls correct URL once", async () => {
const urlToCall = "http://www.test.com/path?param1=value1";
request.url = `/cors?url=${urlToCall}`;
await cors(request, corsResponse);
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][0]).toBe(urlToCall);
});
it("Forwards Content-Type if json", async () => {
fetchResponseHeadersGet.mockImplementation(() => "json");
await cors(request, corsResponse);
expect(fetchResponseHeadersGet.mock.calls).toHaveLength(1);
expect(fetchResponseHeadersGet.mock.calls[0][0]).toBe("Content-Type");
expect(corsResponse.set.mock.calls).toHaveLength(1);
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
expect(corsResponse.set.mock.calls[0][1]).toBe("json");
});
it("Forwards Content-Type if xml", async () => {
fetchResponseHeadersGet.mockImplementation(() => "xml");
await cors(request, corsResponse);
expect(fetchResponseHeadersGet.mock.calls).toHaveLength(1);
expect(fetchResponseHeadersGet.mock.calls[0][0]).toBe("Content-Type");
expect(corsResponse.set.mock.calls).toHaveLength(1);
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
expect(corsResponse.set.mock.calls[0][1]).toBe("xml");
});
it("Sends correct data from response", async () => {
const responseData = "some data";
const encoder = new TextEncoder();
const arrayBuffer = encoder.encode(responseData).buffer;
fetchResponseArrayBuffer.mockImplementation(() => arrayBuffer);
let sentData;
corsResponse.send = vi.fn((input) => {
sentData = input;
});
await cors(request, corsResponse);
expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1);
expect(sentData).toEqual(Buffer.from(arrayBuffer));
});
it("Sends error data from response", async () => {
const error = new Error("error data");
fetchResponseArrayBuffer.mockImplementation(() => {
throw error;
});
await cors(request, corsResponse);
expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1);
expect(corsResponse.status).toHaveBeenCalledWith(500);
expect(corsResponse.json).toHaveBeenCalledWith({ error: error.message });
});
it("Fetches with user agent by default", async () => {
await cors(request, corsResponse);
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("User-Agent");
});
it("Fetches with specified headers", async () => {
const headersParam = "sendheaders=header1:value1,header2:value2";
const urlParam = "http://www.test.com/path?param1=value1";
request.url = `/cors?${headersParam}&url=${urlParam}`;
await cors(request, corsResponse);
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header1", "value1");
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header2", "value2");
});
it("Sends specified headers", async () => {
fetchResponseHeadersGet.mockImplementation((input) => input.replace("header", "value"));
const expectedheaders = "expectedheaders=header1,header2";
const urlParam = "http://www.test.com/path?param1=value1";
request.url = `/cors?${expectedheaders}&url=${urlParam}`;
await cors(request, corsResponse);
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
expect(corsResponse.set.mock.calls).toHaveLength(3);
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
expect(corsResponse.set.mock.calls[1][0]).toBe("header1");
expect(corsResponse.set.mock.calls[1][1]).toBe("value1");
expect(corsResponse.set.mock.calls[2][0]).toBe("header2");
expect(corsResponse.set.mock.calls[2][1]).toBe("value2");
});
it("Gets User-Agent from configuration", () => {
const previousConfig = global.config;
global.config = {};
let userAgent;
userAgent = getUserAgent();
expect(userAgent).toContain("Mozilla/5.0 (Node.js ");
global.config.userAgent = "Mozilla/5.0 (Foo)";
userAgent = getUserAgent();
expect(userAgent).toBe("Mozilla/5.0 (Foo)");
global.config.userAgent = () => "Mozilla/5.0 (Bar)";
userAgent = getUserAgent();
expect(userAgent).toBe("Mozilla/5.0 (Bar)");
global.config = previousConfig;
});
});
describe("The cors method blocks SSRF (DNS rebinding safe)", () => {
let response;
beforeEach(() => {
response = {
set: vi.fn(),
send: vi.fn(),
status: vi.fn(function () { return this; }),
json: vi.fn()
};
});
it("Blocks localhost hostname without DNS", async () => {
await cors({ url: "/cors?url=http://localhost/path" }, response);
expect(response.status).toHaveBeenCalledWith(403);
expect(response.json).toHaveBeenCalledWith({ error: "Forbidden: private or reserved addresses are not allowed" });
});
it("Blocks non-http protocols", async () => {
await cors({ url: "/cors?url=ftp://example.com/file" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});
it("Blocks invalid URLs", async () => {
await cors({ url: "/cors?url=not_a_valid_url" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});
it("Blocks loopback addresses (127.0.0.1)", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 });
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});
it("Blocks RFC 1918 private addresses (192.168.x.x)", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "192.168.1.1", family: 4 });
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});
it("Blocks link-local / cloud metadata addresses (169.254.169.254)", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "169.254.169.254", family: 4 });
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});
it("Allows public unicast addresses", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
headers: { get: vi.fn() },
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
});
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).not.toHaveBeenCalledWith(403);
});
});
describe("cors method with allowWhitelist", () => {
let response;
beforeEach(() => {
response = {
set: vi.fn(),
send: vi.fn(),
status: vi.fn(function () { return this; }),
json: vi.fn()
};
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
headers: { get: vi.fn() },
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
});
});
it("Blocks domains not in whitelist", async () => {
global.config = { cors: "allowWhitelist", corsDomainWhitelist: [] };
await cors({ url: "/cors?url=http://example.com/api" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});
it("Allows domains in whitelist", async () => {
global.config = { cors: "allowWhitelist", corsDomainWhitelist: ["example.com"] };
await cors({ url: "/cors?url=http://example.com/api" }, response);
expect(response.status).not.toHaveBeenCalledWith(403);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("UpdateHelper", () => {
const originalEnv = { ...process.env };
const tempRoots = [];
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
global.version = "test";
global.root_path = process.cwd();
});
afterEach(() => {
process.env = originalEnv;
vi.useRealTimers();
vi.restoreAllMocks();
for (const tempRoot of tempRoots) {
rmSync(tempRoot, { recursive: true, force: true });
}
tempRoots.length = 0;
});
/**
* Creates a temporary MagicMirror-like root with a module directory.
* @param {string} moduleName - Name of the module directory to create.
* @returns {{ root: string, modulePath: string }} Created paths.
*/
function createTempModuleRoot (moduleName) {
const root = mkdtempSync(join(tmpdir(), "mm-updater-"));
const modulePath = join(root, "modules", moduleName);
mkdirSync(modulePath, { recursive: true });
tempRoots.push(root);
return { root, modulePath };
}
/**
* Creates a fresh UpdateHelper instance for testing.
* @param {object} config - Optional config overrides.
* @returns {Promise<object>} Resolved UpdateHelper instance.
*/
async function createUpdater (config = {}) {
const updateHelperModule = await import("../../../defaultmodules/updatenotification/update_helper");
const UpdateHelper = updateHelperModule.default || updateHelperModule;
return new UpdateHelper({ updates: [], updateTimeout: 1000, updateAutorestart: false, ...config });
}
it("marks update as requiring manual restart when autoRestart is disabled", async () => {
const moduleName = "MMM-Test";
const { root } = createTempModuleRoot(moduleName);
global.root_path = root;
const updater = await createUpdater({ updateAutorestart: false });
const result = await updater.updateProcess({
name: moduleName,
updateCommand: `"${process.execPath}" -p 1`
});
expect(result.error).toBe(false);
expect(result.updated).toBe(true);
expect(result.needRestart).toBe(true);
});
it("schedules node restart when autoRestart is enabled", async () => {
vi.useFakeTimers();
const moduleName = "MMM-Test";
const { root } = createTempModuleRoot(moduleName);
global.root_path = root;
const updater = await createUpdater({ updateAutorestart: true });
const nodeRestartSpy = vi.spyOn(updater, "nodeRestart").mockImplementation(() => {});
const result = await updater.updateProcess({
name: moduleName,
updateCommand: `"${process.execPath}" -p 1`
});
expect(result.error).toBe(false);
expect(result.updated).toBe(true);
expect(result.needRestart).toBe(false);
expect(nodeRestartSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(3000);
expect(nodeRestartSpy).toHaveBeenCalledTimes(1);
});
describe("nodeRestart", () => {
it("exits without spawning when running under PM2", async () => {
process.env.pm_id = "0";
const updater = await createUpdater();
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {});
const spawnSpy = vi.spyOn(updater, "_spawnDetachedSelf").mockImplementation(() => {});
updater.nodeRestart();
expect(exitSpy).toHaveBeenCalledWith(0);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("spawns a detached child process when not running under PM2", async () => {
delete process.env.pm_id;
const updater = await createUpdater();
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {});
const spawnSpy = vi.spyOn(updater, "_spawnDetachedSelf").mockImplementation(() => {});
updater.nodeRestart();
expect(spawnSpy).toHaveBeenCalledOnce();
expect(exitSpy).toHaveBeenCalledOnce();
expect(exitSpy).not.toHaveBeenCalledWith(0);
});
});
});
@@ -0,0 +1,367 @@
import { vi, describe, beforeEach, afterEach, it, expect } from "vitest";
/**
* Creates a fresh GitHelper instance with isolated mocks for each test run.
* @param {{ current: import("vitest").Mock | null }} fsStatSyncMockRef reference to the mocked fs.statSync.
* @param {{ current: { error: import("vitest").Mock; info: import("vitest").Mock } | null }} loggerMockRef reference to logger stubs.
* @param {{ current: import("vitest").MockInstance | null }} execGitSpyRef reference to the execGit spy.
* @returns {Promise<unknown>} resolved GitHelper instance.
*/
async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execGitSpyRef) {
vi.resetModules();
fsStatSyncMockRef.current = vi.fn();
loggerMockRef.current = { error: vi.fn(), info: vi.fn() };
vi.doMock("node:fs", () => ({
statSync: fsStatSyncMockRef.current
}));
vi.doMock("logger", () => loggerMockRef.current);
const defaults = await import("../../../js/defaults");
const gitHelperModule = await import(`../../../${defaults.defaultModulesDir}/updatenotification/git_helper.js`);
const GitHelper = gitHelperModule.default || gitHelperModule;
const instance = new GitHelper();
execGitSpyRef.current = vi.spyOn(instance, "execGit");
instance.__loggerMock = loggerMockRef.current;
return instance;
}
describe("Updatenotification", () => {
const fsStatSyncMockRef = { current: null };
const loggerMockRef = { current: null };
const execGitSpyRef = { current: null };
let gitHelper;
let gitRemoteOut;
let gitRevParseOut;
let gitStatusOut;
let gitFetchOut;
let gitRevListCountOut;
let gitRevListOut;
let gitFetchErr;
let gitTagListOut;
const getExecutedCommands = () => execGitSpyRef.current.mock.calls.map((call) => call.slice(1).join(" "));
beforeEach(async () => {
gitHelper = await createGitHelper(fsStatSyncMockRef, loggerMockRef, execGitSpyRef);
fsStatSyncMockRef.current.mockReturnValue({ isDirectory: () => true });
gitRemoteOut = "";
gitRevParseOut = "";
gitStatusOut = "";
gitFetchOut = "";
gitRevListCountOut = "";
gitRevListOut = "";
gitFetchErr = "";
gitTagListOut = "";
execGitSpyRef.current.mockImplementation((_folder, ...args) => {
const command = args.join(" ");
if (command === "remote -v") {
return Promise.resolve({ stdout: gitRemoteOut, stderr: "" });
}
if (command === "rev-parse HEAD") {
return Promise.resolve({ stdout: gitRevParseOut, stderr: "" });
}
if (command === "status -sb") {
return Promise.resolve({ stdout: gitStatusOut, stderr: "" });
}
if (command === "fetch -n --dry-run") {
return Promise.resolve({ stdout: gitFetchOut, stderr: gitFetchErr });
}
if (command.startsWith("rev-list --ancestry-path --count ")) {
return Promise.resolve({ stdout: gitRevListCountOut, stderr: "" });
}
if (command.startsWith("rev-list --ancestry-path ")) {
return Promise.resolve({ stdout: gitRevListOut, stderr: "" });
}
if (command === "ls-remote -q --tags --refs") {
return Promise.resolve({ stdout: gitTagListOut, stderr: "" });
}
return Promise.resolve({ stdout: "", stderr: "" });
});
if (gitHelper.execGit !== execGitSpyRef.current) {
throw new Error("execGit spy not applied");
}
});
afterEach(() => {
gitHelper.gitRepos = [];
vi.resetAllMocks();
});
describe("getRefDiffFromFetchDryRun", () => {
it("extracts only commit range from matching fetch line", () => {
const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 develop -> origin/develop\n";
expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "develop")).toBe("60e0377..332e429");
});
it("matches branch name exactly", () => {
const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n1111111..2222222 main-feature -> origin/main-feature\n3333333..4444444 main -> origin/main\n";
expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "main")).toBe("3333333..4444444");
});
it("returns fallback range when matching line is missing", () => {
const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n";
expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "develop")).toBe("develop..origin/develop");
});
});
describe("MagicMirror on develop", () => {
const moduleName = "MagicMirror";
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## develop...origin/develop\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 develop -> origin/develop\n";
gitRevListCountOut = "5";
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 60e0377..332e429",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
gitStatusOut = "## develop...origin/develop [behind 5]";
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
expect(gitHelper.gitRepos).toHaveLength(1);
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
expect(execGitSpyRef.current.mock.calls.length).toBeGreaterThan(0);
});
});
describe("MagicMirror on master (empty taglist)", () => {
const moduleName = "MagicMirror";
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
gitRevListCountOut = "5";
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 60e0377..332e429",
"ls-remote -q --tags --refs",
"rev-list --ancestry-path 60e0377..332e429",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
gitStatusOut = "## master...origin/master [behind 5]";
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 60e0377..332e429",
"ls-remote -q --tags --refs",
"rev-list --ancestry-path 60e0377..332e429",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
});
});
describe("MagicMirror on master with match in taglist", () => {
const moduleName = "MagicMirror";
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
gitRevListCountOut = "5";
gitTagListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\ttag\n";
gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n";
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 60e0377..332e429",
"ls-remote -q --tags --refs",
"rev-list --ancestry-path 60e0377..332e429",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
gitStatusOut = "## master...origin/master [behind 5]";
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 60e0377..332e429",
"ls-remote -q --tags --refs",
"rev-list --ancestry-path 60e0377..332e429",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
});
});
describe("MagicMirror on master without match in taglist", () => {
const moduleName = "MagicMirror";
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
gitRevListCountOut = "5";
gitTagListOut = "xxxe429a41f1a2339afd4f0ae96dd125da6beada\ttag\n";
gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n";
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 60e0377..332e429",
"ls-remote -q --tags --refs",
"rev-list --ancestry-path 60e0377..332e429",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
gitStatusOut = "## master...origin/master [behind 5]";
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"rev-parse HEAD",
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 60e0377..332e429",
"ls-remote -q --tags --refs",
"rev-list --ancestry-path 60e0377..332e429",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
});
});
describe("custom module", () => {
const moduleName = "MMM-Fuel";
beforeEach(() => {
gitRemoteOut = `origin\thttps://github.com/fewieden/${moduleName}.git (fetch)\norigin\thttps://github.com/fewieden/${moduleName}.git (push)\n`;
gitRevParseOut = "9d8310163da94441073a93cead711ba43e8888d0";
gitStatusOut = "## master...origin/master";
gitFetchErr = `From https://github.com/fewieden/${moduleName}\n19f7faf..9d83101 master -> origin/master`;
gitRevListCountOut = "7";
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information without hash", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"status -sb",
"fetch -n --dry-run",
"rev-list --ancestry-path --count 19f7faf..9d83101",
]
`);
});
});
});
@@ -0,0 +1,15 @@
const fs = require("node:fs");
const path = require("node:path");
const defaults = require("../../../js/defaults");
const root_path = path.join(__dirname, "../../..");
describe("Default modules set in defaultmodules/defaultmodules.js", () => {
const expectedDefaultModules = require(`${root_path}/${defaults.defaultModulesDir}/defaultmodules`);
for (const defaultModule of expectedDefaultModules) {
it(`contains a folder for defaultmodules/${defaultModule}"`, () => {
expect(fs.existsSync(path.join(root_path, "defaultmodules", defaultModule))).toBe(true);
});
}
});
+28
View File
@@ -0,0 +1,28 @@
const fs = require("node:fs");
const path = require("node:path");
const root_path = path.join(__dirname, "../../..");
const version = require(`${root_path}/package.json`).version;
describe("'global.root_path' set in js/app.js", () => {
const expectedSubPaths = ["modules", "serveronly", "js", "js/app.js", "js/main.js", "js/electron.js", "config"];
expectedSubPaths.forEach((subpath) => {
it(`contains a file/folder "${subpath}"`, () => {
expect(fs.existsSync(path.join(root_path, subpath))).toBe(true);
});
});
it("should not modify global.root_path for testing", () => {
expect(global.root_path).toBeUndefined();
});
it("should not modify global.version for testing", () => {
expect(global.version).toBeUndefined();
});
it("should expect the global.version equals package.json file", () => {
const versionPackage = JSON.parse(fs.readFileSync("package.json", "utf8")).version;
expect(version).toBe(versionPackage);
});
});
@@ -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);
});
});
});