Files
zigbee2mqtt/test/settingsMigration.test.ts
Nerivec dd1c449796 feat: Improve OTA (#30566)
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
2026-01-24 21:27:44 +01:00

996 lines
44 KiB
TypeScript

// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "./mocks/data";
import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs";
import objectAssignDeep from "object-assign-deep";
import mockedData from "../lib/util/data";
import * as settings from "../lib/util/settings";
import * as settingsMigration from "../lib/util/settingsMigration";
import path from "node:path";
describe("Settings Migration", () => {
beforeAll(() => {});
afterAll(() => {
rmSync(data.mockDir, {recursive: true, force: true});
});
beforeEach(() => {
data.writeDefaultConfiguration();
settings.reRead();
});
afterEach(() => {
settings.testing.CURRENT_VERSION = settings.CURRENT_VERSION;
if (settings.get().version === settings.CURRENT_VERSION) {
// always validate after each test when up to current version (matching current schema)
expect(settings.validate()).toStrictEqual([]);
}
});
it("Fails on unsupported version", () => {
settings.set(["version"], 0);
expect(() => settingsMigration.migrateIfNecessary()).toThrow(
"Your configuration.yaml has an unsupported version 0, expected one of undefined,2",
);
settings.set(["version"], 99999);
expect(() => settingsMigration.migrateIfNecessary()).toThrow(
"Your configuration.yaml has an unsupported version 99999, expected one of undefined,2",
);
});
describe("Migrates v1 to v2", () => {
const BASE_CONFIG = {
homeassistant: false,
mqtt: {
base_topic: "zigbee2mqtt",
server: "mqtt://localhost",
},
serial: {
port: "/dev/dummy",
},
devices: {
"0x18fc2600000d7ae2": {
friendly_name: "bosch_radiator",
},
"0x000b57fffec6a5b2": {
retain: true,
friendly_name: "bulb",
description: "this is my bulb",
},
"0x0017880104e45517": {
retain: true,
friendly_name: "remote",
},
"0x0017880104e45520": {
retain: false,
friendly_name: "button",
},
"0x0017880104e45521": {
retain: false,
friendly_name: "button_double_key",
},
"0x0017880104e45522": {
qos: 1,
retain: false,
friendly_name: "weather_sensor",
},
"0x0017880104e45523": {
retain: false,
friendly_name: "occupancy_sensor",
},
"0x0017880104e45524": {
retain: false,
friendly_name: "power_plug",
},
"0x0017880104e45530": {
retain: false,
friendly_name: "button_double_key_interviewing",
},
"0x0017880104e45540": {
friendly_name: "ikea_onoff",
},
"0x000b57fffec6a5b7": {
retain: false,
friendly_name: "bulb_2",
},
"0x000b57fffec6a5b3": {
retain: false,
friendly_name: "bulb_color",
},
"0x000b57fffec6a5b4": {
retain: false,
friendly_name: "bulb_color_2",
},
"0x0017880104e45541": {
retain: false,
friendly_name: "wall_switch",
},
"0x0017880104e45542": {
retain: false,
friendly_name: "wall_switch_double",
},
"0x0017880104e45543": {
retain: false,
friendly_name: "led_controller_1",
},
"0x0017880104e45544": {
retain: false,
friendly_name: "led_controller_2",
},
"0x0017880104e45545": {
retain: false,
friendly_name: "dimmer_wall_switch",
},
"0x0017880104e45547": {
retain: false,
friendly_name: "curtain",
},
"0x0017880104e45548": {
retain: false,
friendly_name: "fan",
},
"0x0017880104e45549": {
retain: false,
friendly_name: "siren",
},
"0x0017880104e45529": {
retain: false,
friendly_name: "unsupported2",
},
"0x0017880104e45550": {
retain: false,
friendly_name: "thermostat",
},
"0x0017880104e45551": {
retain: false,
friendly_name: "smart vent",
},
"0x0017880104e45552": {
retain: false,
friendly_name: "j1",
},
"0x0017880104e45553": {
retain: false,
friendly_name: "bulb_enddevice",
},
"0x0017880104e45559": {
retain: false,
friendly_name: "cc2530_router",
},
"0x0017880104e45560": {
retain: false,
friendly_name: "livolo",
},
"0x90fd9ffffe4b64ae": {
retain: false,
friendly_name: "tradfri_remote",
},
"0x90fd9ffffe4b64af": {
friendly_name: "roller_shutter",
},
"0x90fd9ffffe4b64ax": {
friendly_name: "ZNLDP12LM",
},
"0x90fd9ffffe4b64aa": {
friendly_name: "SP600_OLD",
},
"0x90fd9ffffe4b64ab": {
friendly_name: "SP600_NEW",
},
"0x90fd9ffffe4b64ac": {
friendly_name: "MKS-CM-W5",
},
"0x0017880104e45526": {
friendly_name: "GL-S-007ZS",
},
"0x0017880104e43559": {
friendly_name: "U202DST600ZB",
},
"0xf4ce368a38be56a1": {
retain: false,
friendly_name: "zigfred_plus",
front_surface_enabled: "true",
dimmer_1_enabled: "true",
dimmer_1_dimming_enabled: "true",
dimmer_2_enabled: "true",
dimmer_2_dimming_enabled: "true",
dimmer_3_enabled: "true",
dimmer_3_dimming_enabled: "true",
dimmer_4_enabled: "true",
dimmer_4_dimming_enabled: "true",
cover_1_enabled: "true",
cover_1_tilt_enabled: "true",
cover_2_enabled: "true",
cover_2_tilt_enabled: "true",
},
"0x0017880104e44559": {
friendly_name: "3157100_thermostat",
},
"0x0017880104a44559": {
friendly_name: "J1_cover",
},
"0x0017882104a44559": {
friendly_name: "TS0601_thermostat",
},
"0x0017882104a44560": {
friendly_name: "TS0601_switch",
},
"0x0017882104a44562": {
friendly_name: "TS0601_cover_switch",
},
"0x0017882194e45543": {
friendly_name: "QS-Zigbee-D02-TRIAC-2C-LN",
},
"0x0017880104e45724": {
friendly_name: "GLEDOPTO_2ID",
},
"0x0017880104e45561": {
friendly_name: "temperature_sensor",
},
"0x0017880104e45562": {
friendly_name: "heating_actuator",
},
},
groups: {
1: {
friendly_name: "group_1",
retain: false,
},
2: {
friendly_name: "group_2",
retain: false,
},
15071: {
friendly_name: "group_tradfri_remote",
retain: false,
},
11: {
friendly_name: "group_with_tradfri",
retain: false,
},
12: {
friendly_name: "thermostat_group",
retain: false,
},
14: {
friendly_name: "switch_group",
retain: false,
},
21: {
friendly_name: "gledopto_group",
},
9: {
friendly_name: "ha_discovery_group",
},
},
};
beforeEach(() => {
settings.testing.CURRENT_VERSION = 2; // stop update after this version
data.writeDefaultConfiguration(BASE_CONFIG);
settings.reRead();
});
it("no change needed - only add version", () => {
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
});
it("remove all", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
settings.set(["homeassistant", "legacy_triggers"], true);
settings.set(["homeassistant", "legacy_entity_attributes"], true);
settings.set(["ota", "ikea_ota_use_test_url"], true);
settings.set(["advanced", "homeassistant_legacy_triggers"], true);
settings.set(["advanced", "homeassistant_legacy_entity_attributes"], true);
settings.set(["permit_join"], true);
settings.set(["advanced", "ikea_ota_use_test_url"], true);
settings.set(["advanced", "legacy_api"], true);
settings.set(["advanced", "legacy_availability_payload"], true);
settings.set(["advanced", "soft_reset_timeout"], 12);
settings.set(["advanced", "report"], true);
settings.set(["advanced", "availability_timeout"], 65);
settings.set(["advanced", "availability_blocklist"], ["abcd", "efgh"]);
settings.set(["advanced", "availability_passlist"], ["abcd"]);
settings.set(["advanced", "availability_blacklist"], ["abcd", "efgh"]);
settings.set(["advanced", "availability_whitelist"], ["abcd", "efgh"]);
settings.set(["device_options", "legacy"], true);
settings.set(["devices", "0x18fc2600000d7ae2", "retrieve_state"], true);
settings.set(["devices", "0x000b57fffec6a5b2", "retrieve_state"], true);
settings.set(["groups", "15071", "retrieve_state"], true);
settings.set(["groups", "12", "devices"], ["0x0017880104e45521", "0x0017880104e45524"]);
settings.set(["external_converters"], ["zyx.js"]);
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
permit_join: true,
homeassistant: {
legacy_triggers: true,
legacy_entity_attributes: true,
},
ota: {ikea_ota_use_test_url: true},
advanced: {
homeassistant_legacy_triggers: true,
homeassistant_legacy_entity_attributes: true,
ikea_ota_use_test_url: true,
legacy_api: true,
legacy_availability_payload: true,
soft_reset_timeout: 12,
report: true,
availability_timeout: 65,
availability_blocklist: ["abcd", "efgh"],
availability_passlist: ["abcd"],
availability_blacklist: ["abcd", "efgh"],
availability_whitelist: ["abcd", "efgh"],
},
device_options: {legacy: true},
devices: {
"0x18fc2600000d7ae2": {retrieve_state: true},
"0x000b57fffec6a5b2": {retrieve_state: true},
},
groups: {
15071: {retrieve_state: true},
12: {devices: ["0x0017880104e45521", "0x0017880104e45524"]},
},
external_converters: ["zyx.js"],
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings.advanced).toStrictEqual({});
expect(migratedSettings.device_options).toStrictEqual({});
expect(migratedSettings.ota).toStrictEqual({});
expect(migratedSettings.homeassistant).toStrictEqual({});
// defaults added automatically when pushing to these keys, remove to match against default (verified by above expects)
migratedSettings.homeassistant = false;
delete migratedSettings.advanced;
delete migratedSettings.device_options;
delete migratedSettings.ota;
expect(migratedSettings).toStrictEqual(afterSettings);
expect(existsSync(mockedData.joinPath("configuration_backup_v1.yaml"))).toStrictEqual(true);
const migrationNotes = mockedData.joinPath("migration-1-to-2.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain("homeassistant.legacy_triggers");
expect(migrationNotesContent).toContain("homeassistant.legacy_entity_attributes");
expect(migrationNotesContent).toContain("ota.ikea_ota_use_test_url");
expect(migrationNotesContent).toContain("permit_join");
expect(migrationNotesContent).toContain("advanced.legacy_api");
expect(migrationNotesContent).toContain("advanced.legacy_availability_payload");
expect(migrationNotesContent).toContain("advanced.soft_reset_timeout");
expect(migrationNotesContent).toContain("advanced.report");
expect(migrationNotesContent).toContain("advanced.availability_timeout");
expect(migrationNotesContent).toContain("advanced.availability_blocklist");
expect(migrationNotesContent).toContain("advanced.availability_passlist");
expect(migrationNotesContent).toContain("advanced.availability_blacklist");
expect(migrationNotesContent).toContain("advanced.availability_whitelist");
expect(migrationNotesContent).toContain("device_options.legacy");
expect(migrationNotesContent).toContain("(devices|groups).xyz.retrieve_state");
expect(migrationNotesContent).toContain("groups.xyz.devices");
expect(migrationNotesContent).toContain("External converters are now automatically loaded");
});
it("remove partial", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
settings.set(["advanced", "homeassistant_legacy_triggers"], true);
settings.set(["advanced", "homeassistant_legacy_entity_attributes"], true);
settings.set(["permit_join"], true);
settings.set(["advanced", "ikea_ota_use_test_url"], true);
settings.set(["advanced", "legacy_api"], true);
settings.set(["advanced", "legacy_availability_payload"], false);
settings.set(["advanced", "soft_reset_timeout"], 16);
settings.set(["advanced", "report"], true);
settings.set(["advanced", "availability_timeout"], 64);
settings.set(["advanced", "availability_passlist"], []);
settings.set(["device_options", "legacy"], true);
settings.set(["groups", "12", "devices"], ["0x0017880104e45521", "0x0017880104e45524"]);
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
permit_join: true,
advanced: {
homeassistant_legacy_triggers: true,
homeassistant_legacy_entity_attributes: true,
ikea_ota_use_test_url: true,
legacy_api: true,
legacy_availability_payload: false,
soft_reset_timeout: 16,
report: true,
availability_timeout: 64,
availability_passlist: [],
},
device_options: {legacy: true},
groups: {12: {devices: ["0x0017880104e45521", "0x0017880104e45524"]}},
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings.advanced).toStrictEqual({});
expect(migratedSettings.device_options).toStrictEqual({});
// defaults added automatically when pushing to these keys, remove to match against default (verified by above expects)
migratedSettings.homeassistant = false;
delete migratedSettings.advanced;
delete migratedSettings.device_options;
expect(migratedSettings).toStrictEqual(afterSettings);
expect(existsSync(mockedData.joinPath("configuration_backup_v1.yaml"))).toStrictEqual(true);
const migrationNotes = mockedData.joinPath("migration-1-to-2.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain("homeassistant.legacy_triggers");
expect(migrationNotesContent).toContain("homeassistant.legacy_entity_attributes");
expect(migrationNotesContent).toContain("ota.ikea_ota_use_test_url");
expect(migrationNotesContent).toContain("permit_join");
expect(migrationNotesContent).toContain("advanced.legacy_api");
expect(migrationNotesContent).not.toContain("advanced.legacy_availability_payload"); // was false, no impact
expect(migrationNotesContent).toContain("advanced.soft_reset_timeout");
expect(migrationNotesContent).toContain("advanced.report");
expect(migrationNotesContent).toContain("advanced.availability_timeout");
expect(migrationNotesContent).not.toContain("advanced.availability_blocklist");
expect(migrationNotesContent).not.toContain("advanced.availability_passlist"); // empty array
expect(migrationNotesContent).not.toContain("advanced.availability_blacklist");
expect(migrationNotesContent).not.toContain("advanced.availability_whitelist");
expect(migrationNotesContent).toContain("device_options.legacy");
expect(migrationNotesContent).not.toContain("(devices|groups).xyz.retrieve_state");
expect(migrationNotesContent).toContain("groups.xyz.devices");
});
it("changes log_level", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
afterSettings.advanced = {log_level: "warning"};
settings.set(["advanced", "log_level"], "warn");
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
advanced: {
log_level: "warn",
},
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
expect(existsSync(mockedData.joinPath("configuration_backup_v1.yaml"))).toStrictEqual(true);
const migrationNotes = mockedData.joinPath("migration-1-to-2.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain(`Log level 'warn' has been renamed to 'warning'.`);
});
it("does not changes already migrated log_level", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
afterSettings.advanced = {log_level: "warning"};
settings.set(["advanced", "log_level"], "warning");
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
advanced: {
log_level: "warning",
},
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
expect(existsSync(mockedData.joinPath("configuration_backup_v1.yaml"))).toStrictEqual(true);
const migrationNotes = mockedData.joinPath("migration-1-to-2.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).not.toContain(`Log level 'warn' has been renamed to 'warning'.`);
});
it("does not changes other log_level", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
afterSettings.advanced = {log_level: "info"};
settings.set(["advanced", "log_level"], "info");
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
advanced: {
log_level: "info",
},
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
expect(existsSync(mockedData.joinPath("configuration_backup_v1.yaml"))).toStrictEqual(true);
const migrationNotes = mockedData.joinPath("migration-1-to-2.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).not.toContain(`Log level 'warn' has been renamed to 'warning'.`);
});
it("transfer all", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
afterSettings.advanced = {
transmit_power: 12,
output: "attribute",
};
afterSettings.serial.baudrate = 115200;
afterSettings.serial.rtscts = true;
afterSettings.blocklist = ["abcd"];
afterSettings.passlist = ["efgh"];
afterSettings.homeassistant = {
discovery_topic: "ha_disc",
status_topic: "ha_stat",
};
// Here, the `experimental` section is also explicitly removed after the transfer, so the empty object is gone
// afterSettings.experimental = {}; // caused by pushing to key and removing all
settings.set(["advanced", "homeassistant_discovery_topic"], "ha_disc");
settings.set(["advanced", "homeassistant_status_topic"], "ha_stat");
settings.set(["advanced", "baudrate"], 115200);
settings.set(["advanced", "rtscts"], true); // only deleted since also below
settings.set(["serial", "rtscts"], true);
settings.set(["experimental", "transmit_power"], 12);
settings.set(["experimental", "output"], "attribute");
settings.set(["ban"], ["abcd"]);
settings.set(["whitelist"], ["efgh"]);
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
advanced: {
homeassistant_discovery_topic: "ha_disc",
homeassistant_status_topic: "ha_stat",
baudrate: 115200,
rtscts: true,
},
serial: {
rtscts: true,
},
experimental: {
transmit_power: 12,
output: "attribute",
},
ban: ["abcd"],
whitelist: ["efgh"],
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
expect(existsSync(mockedData.joinPath("configuration_backup_v1.yaml"))).toStrictEqual(true);
const migrationNotes = mockedData.joinPath("migration-1-to-2.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain(
"HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.",
);
expect(migrationNotesContent).toContain(
"HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.",
);
expect(migrationNotesContent).toContain("Baudrate was moved from advanced.baudrate to serial.baudrate.");
expect(migrationNotesContent).toContain("RTSCTS was moved from advanced.rtscts to serial.rtscts.");
expect(migrationNotesContent).toContain("Transmit power was moved from experimental.transmit_power to advanced.transmit_power.");
expect(migrationNotesContent).toContain("Output was moved from experimental.output to advanced.output.");
expect(migrationNotesContent).toContain("ban was renamed to passlist.");
expect(migrationNotesContent).toContain("whitelist was renamed to passlist.");
expect(migrationNotesContent).toContain("The entire experimental section was removed.");
});
it("transfer partial", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 2;
afterSettings.advanced = {}; // caused by pushing to key and removing all
afterSettings.serial.baudrate = 115200;
afterSettings.serial.rtscts = true;
afterSettings.blocklist = ["abcd", "efgh"];
afterSettings.homeassistant = {
discovery_topic: "ha_disc_newer", // keeps the newer value, just removes the old path
};
settings.set(["homeassistant", "discovery_topic"], "ha_disc_newer");
settings.set(["advanced", "homeassistant_discovery_topic"], "ha_disc");
settings.set(["advanced", "baudrate"], 115200);
settings.set(["advanced", "rtscts"], true); // only deleted since also below
settings.set(["serial", "rtscts"], true);
settings.set(["ban"], ["abcd"]);
settings.set(["blocklist"], ["efgh"]);
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
homeassistant: {discovery_topic: "ha_disc_newer"},
advanced: {
homeassistant_discovery_topic: "ha_disc",
baudrate: 115200,
rtscts: true,
},
serial: {
rtscts: true,
},
ban: ["abcd"],
blocklist: ["efgh"],
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
expect(existsSync(mockedData.joinPath("configuration_backup_v1.yaml"))).toStrictEqual(true);
const migrationNotes = mockedData.joinPath("migration-1-to-2.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain("[TRANSFER] Baudrate was moved from advanced.baudrate to serial.baudrate.");
expect(migrationNotesContent).toContain("[REMOVAL] RTSCTS was moved from advanced.rtscts to serial.rtscts.");
expect(migrationNotesContent).toContain("[TRANSFER] ban was renamed to passlist.");
});
});
describe("Migrates v1 to v3", () => {
const BASE_CONFIG = {
mqtt: {
server: "mqtt://localhost",
},
};
beforeEach(() => {
settings.testing.CURRENT_VERSION = 3; // stop update after this version
data.writeDefaultConfiguration(BASE_CONFIG);
settings.reRead();
});
it("Update", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 3;
afterSettings.homeassistant = {enabled: false};
afterSettings.frontend = {enabled: true};
afterSettings.availability = {enabled: true, active: {timeout: 15}};
afterSettings.advanced = {
log_level: "warning",
transmit_power: 12,
};
settings.set(["homeassistant"], false);
settings.set(["frontend"], true);
settings.set(["availability"], {active: {timeout: 15}});
settings.set(["permit_join"], true);
settings.set(["advanced", "log_level"], "warn");
settings.set(["experimental", "transmit_power"], 12);
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
homeassistant: false,
frontend: true,
availability: {active: {timeout: 15}},
permit_join: true,
advanced: {log_level: "warn"},
experimental: {transmit_power: 12},
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
});
});
describe("Migrates v2 to v3", () => {
const BASE_CONFIG = {
version: 2,
mqtt: {
server: "mqtt://localhost",
},
};
beforeEach(() => {
settings.testing.CURRENT_VERSION = 3; // stop update after this version
data.writeDefaultConfiguration(BASE_CONFIG);
settings.reRead();
});
it("Update", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 3;
afterSettings.homeassistant = {enabled: false};
afterSettings.frontend = {enabled: true};
afterSettings.availability = {enabled: true, active: {timeout: 15}};
settings.set(["homeassistant"], false);
settings.set(["frontend"], true);
settings.set(["availability"], {active: {timeout: 15}});
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
homeassistant: false,
frontend: true,
availability: {active: {timeout: 15}},
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
const migrationNotes = mockedData.joinPath("migration-2-to-3.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain(`[SPECIAL] Property 'homeassistant' is now always an object.`);
expect(migrationNotesContent).toContain(`[SPECIAL] Property 'frontend' is now always an object.`);
expect(migrationNotesContent).toContain(`[SPECIAL] Property 'availability' is now always an object.`);
});
it("Update when not set, tests that frontend/availability is not added when not set", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 3;
afterSettings.homeassistant = {enabled: false};
settings.set(["homeassistant"], false);
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
homeassistant: false,
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
const migrationNotes = mockedData.joinPath("migration-2-to-3.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain(`[SPECIAL] Property 'homeassistant' is now always an object.`);
expect(migrationNotesContent).not.toContain(`[SPECIAL] Property 'frontend' is now always an object.`);
expect(migrationNotesContent).not.toContain(`[SPECIAL] Property 'availability' is now always an object.`);
});
});
describe("Migrates v3 to v4", () => {
const BASE_CONFIG = {
version: 3,
mqtt: {
server: "mqtt://localhost",
},
};
beforeEach(() => {
settings.testing.CURRENT_VERSION = 4; // stop update after this version
data.writeDefaultConfiguration(BASE_CONFIG);
settings.reRead();
});
it("Update", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 4;
afterSettings.devices = {
"0x123127fffe8d96bc": {
friendly_name: "0x847127fffe8d96bc",
icon: "device_icons/08a9016bbc0657cf5f581ae9c19c31a5.png",
},
"0x223127fffe8d96bc": {
friendly_name: "0x223127fffe8d96bc",
icon: "device_icons/effcad234beeb56ea7c457cf2d36d10b.png",
},
"0x323127fffe8d96bc": {
friendly_name: "0x323127fffe8d96bc",
},
};
settings.set(["devices"], {
"0x123127fffe8d96bc": {
friendly_name: "0x847127fffe8d96bc",
icon: "device_icons/08a9016bbc0657cf5f581ae9c19c31a5.png",
},
"0x223127fffe8d96bc": {
friendly_name: "0x223127fffe8d96bc",
icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ",
},
"0x323127fffe8d96bc": {
friendly_name: "0x323127fffe8d96bc",
},
});
expect(settings.getPersistedSettings()).toStrictEqual(
// @ts-expect-error workaround
objectAssignDeep.noMutate(beforeSettings, {
devices: {
"0x123127fffe8d96bc": {
friendly_name: "0x847127fffe8d96bc",
icon: "device_icons/08a9016bbc0657cf5f581ae9c19c31a5.png",
},
"0x223127fffe8d96bc": {
friendly_name: "0x223127fffe8d96bc",
icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ",
},
"0x323127fffe8d96bc": {
friendly_name: "0x323127fffe8d96bc",
},
},
}),
);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
const migrationNotes = mockedData.joinPath("migration-3-to-4.log");
expect(existsSync(migrationNotes)).toStrictEqual(true);
const migrationNotesContent = readFileSync(migrationNotes, "utf8");
expect(migrationNotesContent).toContain("[SPECIAL] Device icons are now saved as images.");
});
});
describe("Migrates v4 to v5", () => {
const BASE_CONFIG = {
version: 4,
mqtt: {
server: "mqtt://localhost",
},
};
const DEFAULT_STATE: Record<string, unknown> = {
"0x000b57fffec6a5b2": {
state: "ON",
brightness: 50,
color_temp: 370,
linkquality: 99,
},
"0x0017880104e45517": {
brightness: 255,
update: {state: "idle"},
},
1: {
state: "ON",
update: {state: "available", installed_version: 1, latest_version: 2},
},
};
beforeEach(() => {
settings.testing.CURRENT_VERSION = 5; // stop update after this version
data.writeDefaultConfiguration(BASE_CONFIG);
data.writeDefaultState(DEFAULT_STATE);
settings.reRead();
});
it("Update", () => {
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 5;
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
expect(data.readState()).toStrictEqual(DEFAULT_STATE);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
// @ts-expect-error workaround
const migratedState = objectAssignDeep.noMutate({}, DEFAULT_STATE);
delete (migratedState["0x0017880104e45517"] as Record<string, unknown>).update;
delete (migratedState[1] as Record<string, unknown>).update;
expect(data.readState()).toStrictEqual(migratedState);
});
it("handles errors gracefully", () => {
const consoleErrorSpy = vi.spyOn(console, "error");
writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8");
// @ts-expect-error workaround
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
// @ts-expect-error workaround
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
afterSettings.version = 5;
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
settingsMigration.migrateIfNecessary();
const migratedSettings = settings.getPersistedSettings();
expect(migratedSettings).toStrictEqual(afterSettings);
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to write state"));
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("is not valid JSON"));
});
});
});