chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled
Enforce Pull-Request Rules / check (push) Has been cancelled
This commit is contained in:
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 <em>Atlantic</em> 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 = "That’s Enough, <em>Euphoria</em>";
|
||||
expect(sanitize("That’s Enough, <em>Euphoria</em>")).toBe(expected);
|
||||
expect(sanitize("That’s Enough, <em>Euphoria</em>")).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("<script>alert(1)</script>");
|
||||
expect(encoded).not.toContain("<script");
|
||||
expect(encoded).toContain("<script>");
|
||||
});
|
||||
|
||||
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 & Chips for < 5")).toBe("Fish & Chips for < 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> & <b>bye</b>", [])).toBe("hi & 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("<br>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user