src.nth.io/

summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/commissioning.test.ts33
-rw-r--r--test/config.test.ts73
-rw-r--r--test/fabrics.test.ts46
-rw-r--r--test/state.test.ts103
-rw-r--r--test/sync.test.ts111
-rw-r--r--test/timezone.test.ts66
6 files changed, 432 insertions, 0 deletions
diff --git a/test/commissioning.test.ts b/test/commissioning.test.ts
new file mode 100644
index 0000000..676a5e1
--- /dev/null
+++ b/test/commissioning.test.ts
@@ -0,0 +1,33 @@
+import { ManualPairingCodeCodec } from "@matter/main/types";
+import { describe, expect, it } from "vitest";
+import { parsePairingCode } from "../src/commissioning.js";
+
+describe("parsePairingCode", () => {
+ const validCode = ManualPairingCodeCodec.encode({ discriminator: 3840, passcode: 20202021 });
+
+ it("parses a valid 11-digit manual code", () => {
+ const parsed = parsePairingCode(validCode);
+ expect(parsed.passcode).toBe(20202021);
+ expect(parsed.shortDiscriminator).toBe(3840 >> 8);
+ });
+
+ it("accepts hyphens and whitespace", () => {
+ const withHyphens = `${validCode.slice(0, 4)}-${validCode.slice(4, 7)}-${validCode.slice(7)}`;
+ expect(parsePairingCode(` ${withHyphens} `).passcode).toBe(20202021);
+ });
+
+ it("rejects empty input", () => {
+ expect(() => parsePairingCode("")).toThrow(/empty/);
+ expect(() => parsePairingCode(" ")).toThrow(/empty/);
+ });
+
+ it("rejects codes of the wrong length", () => {
+ expect(() => parsePairingCode("12345")).toThrow(/11 or 21 digits/);
+ expect(() => parsePairingCode("123456789012")).toThrow(/11 or 21 digits/);
+ });
+
+ it("rejects codes with an invalid checksum", () => {
+ const corrupted = validCode.slice(0, -1) + ((Number(validCode.at(-1)) + 1) % 10).toString();
+ expect(() => parsePairingCode(corrupted)).toThrow();
+ });
+});
diff --git a/test/config.test.ts b/test/config.test.ts
new file mode 100644
index 0000000..1caecd8
--- /dev/null
+++ b/test/config.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { ConfigError, parseNodeId, validateConfig } from "../src/config.js";
+
+const minimal = { storagePath: "/var/lib/mattertimesync" };
+
+describe("validateConfig", () => {
+ it("accepts a minimal configuration and applies defaults", () => {
+ const config = validateConfig(minimal);
+ expect(config.storagePath).toBe(minimal.storagePath);
+ expect(config.timezone).toBe("America/Chicago");
+ expect(config.logLevel).toBe("info");
+ });
+
+ it("accepts a full configuration", () => {
+ const config = validateConfig({
+ ...minimal,
+ timezone: "Europe/Berlin",
+ logLevel: "debug",
+ });
+ expect(config.timezone).toBe("Europe/Berlin");
+ expect(config.logLevel).toBe("debug");
+ });
+
+ it("rejects non-object documents", () => {
+ expect(() => validateConfig([])).toThrow(ConfigError);
+ expect(() => validateConfig("x")).toThrow(ConfigError);
+ expect(() => validateConfig(null)).toThrow(ConfigError);
+ });
+
+ it("requires storagePath", () => {
+ expect(() => validateConfig({})).toThrow(/storagePath/);
+ expect(() => validateConfig({ storagePath: "" })).toThrow(/storagePath/);
+ });
+
+ it("rejects unknown fields", () => {
+ expect(() => validateConfig({ ...minimal, unexpected: 1 })).toThrow(/unknown field "unexpected"/);
+ // Fields from abandoned designs are rejected, not ignored.
+ expect(() => validateConfig({ ...minimal, syncIntervalHours: 24 })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, syncOnReconnect: true })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, nodeId: "1" })).toThrow(/unknown field/);
+ });
+
+ it("rejects invalid time zones", () => {
+ expect(() => validateConfig({ ...minimal, timezone: "Central Time" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: "" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: 5 })).toThrow(/timezone/);
+ });
+
+ it("rejects invalid log levels", () => {
+ expect(() => validateConfig({ ...minimal, logLevel: "verbose" })).toThrow(/logLevel/);
+ });
+});
+
+describe("parseNodeId", () => {
+ it("parses decimal strings without precision loss", () => {
+ expect(parseNodeId("1")).toBe(1n);
+ expect(parseNodeId("9007199254740993")).toBe(9007199254740993n);
+ expect(parseNodeId("18446744073709551615")).toBe(18446744073709551615n);
+ });
+
+ it("accepts small safe integers", () => {
+ expect(parseNodeId(42)).toBe(42n);
+ });
+
+ it("rejects unsafe or invalid values", () => {
+ expect(() => parseNodeId("18446744073709551616")).toThrow(ConfigError); // 2^64
+ expect(() => parseNodeId("-1")).toThrow(ConfigError);
+ expect(() => parseNodeId(1.5)).toThrow(ConfigError);
+ expect(() => parseNodeId("0x10")).toThrow(ConfigError);
+ expect(() => parseNodeId(Number.MAX_SAFE_INTEGER + 2)).toThrow(ConfigError);
+ expect(() => parseNodeId(null)).toThrow(ConfigError);
+ });
+});
diff --git a/test/fabrics.test.ts b/test/fabrics.test.ts
new file mode 100644
index 0000000..b3cd18e
--- /dev/null
+++ b/test/fabrics.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { findLikelyStrays, formatFabricTable, type FabricTable } from "../src/fabrics.js";
+
+const table: FabricTable = {
+ supportedFabrics: 5,
+ commissionedFabrics: 4,
+ entries: [
+ { fabricIndex: 1, fabricId: 100n, nodeId: 10n, vendorId: 0x1349, label: "Apple Home", isOurs: false },
+ { fabricIndex: 2, fabricId: 200n, nodeId: 20n, vendorId: 0xfff1, label: "mattertimesync", isOurs: true },
+ { fabricIndex: 3, fabricId: 300n, nodeId: 30n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ { fabricIndex: 4, fabricId: 400n, nodeId: 40n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ ],
+};
+
+describe("findLikelyStrays", () => {
+ it("flags entries with our label that are not our identity", () => {
+ expect(findLikelyStrays(table).map(entry => entry.fabricIndex)).toEqual([3, 4]);
+ });
+
+ it("never flags other ecosystems' entries", () => {
+ const strays = findLikelyStrays(table);
+ expect(strays.some(entry => entry.label === "Apple Home")).toBe(false);
+ });
+
+ it("flags nothing when our identity is not in the table", () => {
+ const withoutOurs: FabricTable = {
+ ...table,
+ entries: table.entries.map(entry => ({ ...entry, isOurs: false })),
+ };
+ expect(findLikelyStrays(withoutOurs)).toEqual([]);
+ });
+});
+
+describe("formatFabricTable", () => {
+ const output = formatFabricTable(1n, table);
+
+ it("shows slot usage and marks our entry", () => {
+ expect(output).toContain("4 of 5 slots used");
+ expect(output).toContain("[this controller]");
+ });
+
+ it("names known vendors and lists stale indices", () => {
+ expect(output).toContain("(Apple)");
+ expect(output).toContain("index 3, index 4");
+ });
+});
diff --git a/test/state.test.ts b/test/state.test.ts
new file mode 100644
index 0000000..a80fcfd
--- /dev/null
+++ b/test/state.test.ts
@@ -0,0 +1,103 @@
+import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ EMPTY_NODE_STATE,
+ nodeState,
+ readServiceState,
+ removeNodeState,
+ serviceStatePath,
+ updateNodeState,
+ writeServiceState,
+} from "../src/state.js";
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "mattertimesync-state-"));
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("service state", () => {
+ it("returns empty state when the file does not exist", () => {
+ expect(readServiceState(join(dir, "missing.json"))).toEqual({ nodes: {} });
+ });
+
+ it("round-trips per-node state through disk", () => {
+ const path = serviceStatePath(dir);
+ const state = {
+ nodes: {
+ "12345678901234567890": {
+ lastSuccessfulConnection: "2026-07-26T20:00:00Z",
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ lastError: null,
+ },
+ "2": { ...EMPTY_NODE_STATE, lastError: "unreachable" },
+ },
+ };
+ writeServiceState(path, state);
+ expect(readServiceState(path)).toEqual(state);
+ });
+
+ it("leaves no temporary file behind after writing", () => {
+ const path = serviceStatePath(dir);
+ writeServiceState(path, { nodes: {} });
+ expect(readdirSync(dir)).toEqual(["service-state.json"]);
+ });
+
+ it("returns empty state for a corrupt file instead of throwing", () => {
+ const path = join(dir, "corrupt.json");
+ writeFileSync(path, "{ this is not json");
+ expect(readServiceState(path)).toEqual({ nodes: {} });
+ });
+
+ it("drops malformed node keys and wrongly typed fields", () => {
+ const path = join(dir, "typed.json");
+ writeFileSync(
+ path,
+ JSON.stringify({
+ nodes: {
+ "not-a-node-id": { lastError: "x" },
+ "7": { lastError: ["array"], lastSuccessfulSync: "2026-07-26T20:00:02Z" },
+ },
+ }),
+ );
+ expect(readServiceState(path)).toEqual({
+ nodes: { "7": { ...EMPTY_NODE_STATE, lastSuccessfulSync: "2026-07-26T20:00:02Z" } },
+ });
+ });
+
+ it("merges patches into one node without touching others", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, { lastSuccessfulSync: "2026-07-26T20:00:02Z" });
+ updateNodeState(path, 1n, { lastAttemptedSync: "2026-07-26T20:00:01Z" });
+
+ const state = readServiceState(path);
+ expect(nodeState(state, 1n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastError: "boom",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ });
+ expect(nodeState(state, 2n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ });
+ });
+
+ it("removes a node's entry", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, {});
+ removeNodeState(path, 1n);
+
+ const state = readServiceState(path);
+ expect(Object.keys(state.nodes)).toEqual(["2"]);
+ expect(nodeState(state, 1n)).toEqual(EMPTY_NODE_STATE);
+ });
+});
diff --git a/test/sync.test.ts b/test/sync.test.ts
new file mode 100644
index 0000000..0bc5dc9
--- /dev/null
+++ b/test/sync.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import {
+ MATTER_EPOCH_UNIX_SECONDS,
+ compareDeviceClock,
+ currentMatterUtcMicroseconds,
+ formatDurationMicros,
+ formatMatterMicros,
+ matterMicrosToUnixMillis,
+ unixMillisToMatterMicros,
+} from "../src/sync.js";
+
+describe("Matter epoch conversion", () => {
+ it("maps 2000-01-01T00:00:00Z to 0 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:00Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(0n);
+ });
+
+ it("maps 2000-01-01T00:00:01Z to 1,000,000 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:01Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(1_000_000n);
+ });
+
+ it("keeps the Unix epoch conversion exact", () => {
+ expect(unixMillisToMatterMicros(0n)).toBe(-MATTER_EPOCH_UNIX_SECONDS * 1_000_000n);
+ expect(unixMillisToMatterMicros(0n)).toBe(-946_684_800_000_000n);
+ });
+
+ it("keeps current dates exact through bigint round-trips", () => {
+ const unixMs = BigInt(Date.parse("2026-07-26T20:00:00.123Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros).toBe((unixMs - 946_684_800_000n) * 1_000n);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("stays exact beyond JavaScript number precision", () => {
+ // Microsecond timestamps pass Number.MAX_SAFE_INTEGER around year 2285;
+ // conversion must not degrade there, proving no number arithmetic occurs.
+ const unixMs = BigInt(Date.parse("2300-01-01T00:00:00.001Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("computes the current time via the injected clock", () => {
+ const nowMs = Date.parse("2026-07-26T20:00:00Z");
+ const micros = currentMatterUtcMicroseconds(() => nowMs);
+ expect(micros).toBe(unixMillisToMatterMicros(BigInt(nowMs)));
+ });
+
+ it("formats Matter microseconds as an ISO instant", () => {
+ expect(formatMatterMicros(0n)).toBe("2000-01-01T00:00:00.000Z");
+ expect(formatMatterMicros(1_000_000n)).toBe("2000-01-01T00:00:01.000Z");
+ });
+});
+
+describe("formatDurationMicros", () => {
+ it("scales units with magnitude", () => {
+ expect(formatDurationMicros(0n)).toBe("0us");
+ expect(formatDurationMicros(999n)).toBe("999us");
+ expect(formatDurationMicros(412_000n)).toBe("412ms");
+ expect(formatDurationMicros(3_200_000n)).toBe("3.2s");
+ expect(formatDurationMicros(59_000_000n)).toBe("59s");
+ expect(formatDurationMicros(125_000_000n)).toBe("2m 5s");
+ expect(formatDurationMicros(3_840_000_000n)).toBe("1h 4m");
+ // 90,061 seconds = 1d 1h 1m 1s; seconds are dropped once days appear.
+ expect(formatDurationMicros(90_061_000_000n)).toBe("1d 1h 1m");
+ });
+
+ it("rejects negative durations", () => {
+ expect(() => formatDurationMicros(-1n)).toThrow();
+ });
+});
+
+describe("compareDeviceClock", () => {
+ const host = unixMillisToMatterMicros(BigInt(Date.parse("2026-07-26T20:00:00.000Z")));
+
+ it("reports an unset device clock", () => {
+ const comparison = compareDeviceClock(null, host);
+ expect(comparison.deviceTime).toBeNull();
+ expect(comparison.deltaMicroseconds).toBeNull();
+ expect(comparison.hostTime).toBe("2026-07-26T20:00:00.000Z");
+ expect(comparison.description).toBe("device clock was unset");
+ });
+
+ it("reports a device clock behind the host", () => {
+ const comparison = compareDeviceClock(host - 83_000_000n, host);
+ expect(comparison.deviceTime).toBe("2026-07-26T19:58:37.000Z");
+ expect(comparison.deltaMicroseconds).toBe(-83_000_000n);
+ expect(comparison.description).toBe("device clock was 1m 23s behind");
+ });
+
+ it("reports a device clock ahead of the host", () => {
+ const comparison = compareDeviceClock(host + 5_500_000n, host);
+ expect(comparison.deltaMicroseconds).toBe(5_500_000n);
+ expect(comparison.description).toBe("device clock was 5.5s ahead");
+ });
+
+ it("treats sub-second deltas as in sync but still reports them", () => {
+ const comparison = compareDeviceClock(host - 412_000n, host);
+ expect(comparison.description).toBe("device clock was within 1s of host time (412ms behind)");
+ });
+
+ it("handles a device clock wildly in the future (wrong epoch bug)", () => {
+ // A device set with Unix-epoch seconds where Matter-epoch was expected
+ // shows up decades ahead; the delta must stay exact.
+ const wrong = host + 946_684_800n * 1_000_000n;
+ const comparison = compareDeviceClock(wrong, host);
+ expect(comparison.deltaMicroseconds).toBe(946_684_800_000_000n);
+ expect(comparison.description).toContain("ahead");
+ });
+});
diff --git a/test/timezone.test.ts b/test/timezone.test.ts
new file mode 100644
index 0000000..f6c2230
--- /dev/null
+++ b/test/timezone.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { formatUtcOffset, isValidTimeZone, nextOffsetTransition, utcOffsetSeconds } from "../src/timezone.js";
+
+describe("isValidTimeZone", () => {
+ it("accepts IANA names", () => {
+ expect(isValidTimeZone("America/Chicago")).toBe(true);
+ expect(isValidTimeZone("Europe/Berlin")).toBe(true);
+ expect(isValidTimeZone("UTC")).toBe(true);
+ });
+
+ it("rejects invalid names", () => {
+ expect(isValidTimeZone("Central Time")).toBe(false);
+ expect(isValidTimeZone("")).toBe(false);
+ expect(isValidTimeZone("America/Springfield")).toBe(false);
+ });
+});
+
+describe("utcOffsetSeconds", () => {
+ it("returns CST offset in January", () => {
+ expect(utcOffsetSeconds("America/Chicago", new Date("2026-01-15T12:00:00Z"))).toBe(-6 * 3600);
+ });
+
+ it("returns CDT offset in July", () => {
+ expect(utcOffsetSeconds("America/Chicago", new Date("2026-07-15T12:00:00Z"))).toBe(-5 * 3600);
+ });
+
+ it("returns 0 for UTC", () => {
+ expect(utcOffsetSeconds("UTC", new Date("2026-07-15T12:00:00Z"))).toBe(0);
+ });
+
+ it("handles half-hour offsets", () => {
+ expect(utcOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T12:00:00Z"))).toBe(5.5 * 3600);
+ });
+});
+
+describe("formatUtcOffset", () => {
+ it("formats negative, positive, and zero offsets", () => {
+ expect(formatUtcOffset(-6 * 3600)).toBe("UTC-06:00");
+ expect(formatUtcOffset(5.5 * 3600)).toBe("UTC+05:30");
+ expect(formatUtcOffset(0)).toBe("UTC+00:00");
+ });
+});
+
+describe("nextOffsetTransition", () => {
+ it("finds the exact spring-forward instant for Chicago", () => {
+ const transition = nextOffsetTransition("America/Chicago", new Date("2026-01-15T00:00:00Z"));
+ expect(transition).not.toBeNull();
+ // 2026-03-08 02:00 CST (UTC-6) -> 03:00 CDT (UTC-5): 08:00:00 UTC.
+ expect(transition!.at.toISOString()).toBe("2026-03-08T08:00:00.000Z");
+ expect(transition!.offsetBeforeSeconds).toBe(-6 * 3600);
+ expect(transition!.offsetAfterSeconds).toBe(-5 * 3600);
+ });
+
+ it("finds the exact fall-back instant for Chicago", () => {
+ const transition = nextOffsetTransition("America/Chicago", new Date("2026-07-15T00:00:00Z"));
+ expect(transition).not.toBeNull();
+ // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC.
+ expect(transition!.at.toISOString()).toBe("2026-11-01T07:00:00.000Z");
+ expect(transition!.offsetBeforeSeconds).toBe(-5 * 3600);
+ expect(transition!.offsetAfterSeconds).toBe(-6 * 3600);
+ });
+
+ it("returns null for fixed-offset zones", () => {
+ expect(nextOffsetTransition("UTC", new Date("2026-01-15T00:00:00Z"))).toBeNull();
+ });
+});