From 85c8ec1f843109c9798ecdcf2bda4245f5c436b2 Mon Sep 17 00:00:00 2001
From: Marin Karamihalev <marin.karamihalev@iopsys.eu>
Date: Thu, 3 Aug 2023 13:57:52 +0200
Subject: [PATCH] added errors

---
 package.json                     |   2 +-
 src/commands/common/add.ts       |  12 ++-
 src/commands/common/set.ts       |   4 +-
 src/configurations/build.ts      |  11 ++-
 src/errors.ts                    |  41 ++++++++
 src/types.ts                     |  56 ++++++++++-
 src/util.ts                      |  38 ++++++--
 src/verify.ts                    | 148 +++++++++++++++++++++++++++++
 tests/integration/config.json    |   2 +-
 tests/integration/errors.test.ts | 157 +++++++++++++++++++++++++++++++
 tests/integration/index.test.ts  |  17 ++++
 11 files changed, 466 insertions(+), 22 deletions(-)
 create mode 100644 src/errors.ts
 create mode 100644 src/verify.ts
 create mode 100644 tests/integration/errors.test.ts

diff --git a/package.json b/package.json
index e83305e..c1201a5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "usp-js",
-  "version": "0.4.4",
+  "version": "0.4.5",
   "description": "Library for easy usp(TR-369) communication using mqtt or ws.",
   "main": "node/index.js",
   "browser": "web/index.js",
diff --git a/src/commands/common/add.ts b/src/commands/common/add.ts
index 96a18df..9b410a7 100644
--- a/src/commands/common/add.ts
+++ b/src/commands/common/add.ts
@@ -1,6 +1,10 @@
 import { DecodeFn, EncodeFn } from "../../types";
 import * as util from "../util";
-import { AddLookupCreateObject, AddLookupCreateParamSetting } from "../../types";
+import {
+  AddLookupCreateObject,
+  AddLookupCreateParamSetting,
+} from "../../types";
+import { parseSetArgs } from "../../util";
 
 const decode: DecodeFn = (msg) => {
   const paths: string[] | undefined = util.searchAll(msg, "instantiatedPath");
@@ -21,9 +25,9 @@ const makePair = (value): [string, any, boolean][] =>
     : [];
 
 const encode: EncodeFn = ({ value, path }) => {
-  const paths = Array.isArray(path) ? path : [path];
-  const values = value ? (Array.isArray(value) ? value : [value]) : [];
-  const allowPartial = values && values.some((it) => it.allowPartial) || false; 
+  const [paths, values] = parseSetArgs(value, path);
+  const allowPartial =
+    (values && values.some((it) => it.allowPartial)) || false;
 
   const createObjs = paths.map((path, i) => ({
     lookup: "Add.CreateObject" as AddLookupCreateObject,
diff --git a/src/commands/common/set.ts b/src/commands/common/set.ts
index 31fede9..dd63b3e 100644
--- a/src/commands/common/set.ts
+++ b/src/commands/common/set.ts
@@ -6,6 +6,7 @@ import {
   SetLookupUpdateParamSetting,
 } from "../../types";
 import * as util from "../util";
+import { parseSetArgs } from "../../util";
 
 const decode: DecodeFn = (msg, decodeOptions) => {
   if (decodeOptions?.raw) return [msg];
@@ -25,8 +26,7 @@ const makePairs = (path: string, value): [string, any, boolean][] =>
     : [[path.split(".").pop() || "", (value || "").toString(), true]];
 
 const encode: EncodeFn = ({ value, path: initialPath }) => {
-  const paths = Array.isArray(initialPath) ? initialPath : [initialPath];
-  const values = value ? (Array.isArray(value) ? value : [value]) : [];
+  const [paths, values] = parseSetArgs(value, initialPath);
   const allowPartial =
     (values && values.some((it) => it.allowPartial)) || false;
 
diff --git a/src/configurations/build.ts b/src/configurations/build.ts
index 39f864d..5098a5f 100644
--- a/src/configurations/build.ts
+++ b/src/configurations/build.ts
@@ -28,6 +28,8 @@ import {
   makeRouter,
   parseCallbackOptions,
 } from "../util";
+import makeVerify from "../verify";
+import defaultErrors from "../errors";
 
 const defaultPublishEndpoint = "/usp/endpoint";
 const defaultSubscribeEndpoint = "/usp/controller";
@@ -175,7 +177,7 @@ const initializeSession = ({
 };
 
 const buildConnect: BuildConnectionFn =
-  ({ connectClient, decodeID, loadProtobuf }) =>
+  ({ connectClient, decodeID, loadProtobuf, errors = defaultErrors }) =>
   async (options, events) => {
     const subscribeEndpoint =
       options.subscribeEndpoint || defaultSubscribeEndpoint;
@@ -310,8 +312,15 @@ const buildConnect: BuildConnectionFn =
       };
     };
 
+    const verify = makeVerify(errors);
     const call: CallFn = (command, args, callOpts) =>
       new Promise((resolve, reject) => {
+        const error = verify(command, args);
+        if (error) {
+          reject(error);
+          return;
+        }
+
         // todo make looky nice
         sessionOptions.sequenceId++;
         sessionOptions.expectedId = sessionOptions.sequenceId;
diff --git a/src/errors.ts b/src/errors.ts
new file mode 100644
index 0000000..70ee712
--- /dev/null
+++ b/src/errors.ts
@@ -0,0 +1,41 @@
+import { VerifyResult } from "./types";
+
+export const defaultMissingErrorMessage = "Unknown error occurred";
+
+export default {
+  [VerifyResult.NoError]: "No error",
+
+  // GET
+  [VerifyResult.PathsMissing]: "'paths' argument is missing",
+  [VerifyResult.PathsNotStringOrStringArray]:
+    "'paths' argument has to be a string or a string array",
+  [VerifyResult.RawOptionNotBool]: "'raw' option has to be a boolean",
+  [VerifyResult.RetainPathOptionNotBool]:
+    "'retainPath' option has to be a boolean",
+  [VerifyResult.MaxDepthOptionNotNumber]:
+    "'max_depth' option has to be a positive number",
+  [VerifyResult.MaxDepthOptionIsNegative]:
+    "'max_depth' option has to be a positive number",
+
+  // SET / ADD
+  [VerifyResult.ValueIsMissing]: "'value' argument is required",
+  [VerifyResult.ValueMustBeArray]:
+    "'value' argument must be an array, when 'path' is an array",
+  [VerifyResult.ValueAndPathArraysMustBeSameLength]:
+    "'path' and 'value' arrays must have the same length",
+  [VerifyResult.ValueIsNotTupleArray]:
+    "'path' argument must be an array of tuples",
+
+  // DELETE
+  [VerifyResult.AllowPartialOptionNotBool]:
+    "'allowPartial' option has to be a boolean",
+
+  // SUPPORTED_DM
+  [VerifyResult.OptionsMustBeBool]: "all options have to be boolean",
+
+  // INSTANCES
+  [VerifyResult.FirstLevelOnlyNotBool]: "'firstLevelOnly' has to be a boolean",
+
+  // PROTO
+  [VerifyResult.ProtoNotString]: "'proto' argument has to be a string",
+} as Record<VerifyResult, string>;
diff --git a/src/types.ts b/src/types.ts
index fb14a30..321d32d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -130,6 +130,38 @@ export type SetRawResponse = {
   };
 };
 
+export type VerifierFunc = (args: Record<string, any>) => VerifyResult;
+
+export enum VerifyResult {
+  NoError = "VerifyResult.NoError",
+
+  // GET
+  PathsMissing = "VerifyResult.PathsMissing",
+  PathsNotStringOrStringArray = "VerifyResult.PathsNotStringOrStringArray",
+  RawOptionNotBool = "VerifyResult.RawOptionNotBool",
+  RetainPathOptionNotBool = "VerifyResult.RetainPathOptionNotBool",
+  MaxDepthOptionNotNumber = "VerifyResult.MaxDepthOptionNotNumber",
+  MaxDepthOptionIsNegative = "VerifyResult.MaxDepthOptionIsNegative",
+
+  // SET / ADD
+  ValueIsMissing = "VerifyResult.ValueIsMissing",
+  ValueMustBeArray = "VerifyResult.ValueMustBeArray",
+  ValueAndPathArraysMustBeSameLength = "VerifyResult.ValueAndPathArraysMustBeSameLength",
+  ValueIsNotTupleArray = "VerifyResult.ValueIsNotTupleArray",
+
+  // DELETE
+  AllowPartialOptionNotBool = "VerifyResult.AllowPartialOptionNotBool",
+
+  // SUPPORTED_DM
+  OptionsMustBeBool = "VerifyResult.OptionsMustBeBool",
+
+  // INSTANCES
+  FirstLevelOnlyNotBool = "VerifyResult.FirstLevelOnlyNotBool",
+
+  // PROTO
+  ProtoNotString = "VerifyResult.ProtoNotString",
+}
+
 export type GetNestedReturn =
   | UspProperty
   | UspPropertyList
@@ -162,7 +194,11 @@ export type SetCommand = ((
       | (JSValue | JSValue[] | InputRecord)
       | (JSValue | JSValue[] | InputRecord)[],
     options: { raw: true } & SetCommandOptions
-  ) => Promise<SetRawResponse>);
+  ) => Promise<SetRawResponse>) &
+  ((
+    values: [string, JSValue | JSValue[] | InputRecord][],
+    options?: SetCommandOptions
+  ) => Promise<SetResponse>);
 export type AddCommand = ((
   path: string | string[],
   value?:
@@ -180,7 +216,11 @@ export type AddCommand = ((
       | null
       | undefined,
     options: { raw: true }
-  ) => Promise<AddRawResponse>);
+  ) => Promise<AddRawResponse>) &
+  ((
+    values: [string, JSValue | JSValue[] | InputRecord][],
+    options?: { raw?: boolean }
+  ) => Promise<AddResponse>);
 export type DelCommand = (
   path: string | string[],
   allowPartial?: boolean
@@ -452,6 +492,11 @@ export interface USP {
       ["Device.NAT.PortMapping.1.", "Device.NAT.PortMapping.2."],
       [{ Description: "cat-1" }, { Description: "cat-2" }]
     )
+   * // or
+   * await device.set([
+      ["Device.NAT.PortMapping.1.Description", "desc 1"],
+      ["Device.NAT.PortMapping.2.Description", "desc 2"],
+    ]);
    * ```
    */
   set: SetCommand;
@@ -502,6 +547,10 @@ export interface USP {
       ["Device.NAT.PortMapping.", "Device.NAT.PortMapping."],
       [{ Description: "cpe-1" }, { Description: "cpe-2" }]
     );
+   * await device.add([
+      ["Device.NAT.PortMapping.", { Description: "bbbb" }],
+      ["Device.NAT.PortMapping.", { Description: "bbbb" }],
+    ]);
    * ```
    */
   add: AddCommand;
@@ -688,7 +737,7 @@ export type HostConnectionOptions = {
 
 export type CertType = string | string[] | Buffer | Buffer[];
 
-export type USPVersion = typeof knownUSPVersions[number];
+export type USPVersion = (typeof knownUSPVersions)[number];
 
 export interface MainConnectionOptions {
   username: string;
@@ -831,6 +880,7 @@ export type BuildConnectionFn = (connectConfig: {
   connectClient: ConnectClientFn;
   decodeID: DecodeIDFn;
   loadProtobuf: LoadProtobufFn;
+  errors?: Record<VerifyResult, string>;
 }) => Connect;
 
 export type USPSession = {
diff --git a/src/util.ts b/src/util.ts
index 318d87e..a0e2584 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -1,5 +1,10 @@
 import { search } from "./commands/util";
-import { CallbackOptions, CommandType, OnCallback, OnIdent, SubscriptionCallback } from "./types";
+import {
+  CallbackOptions,
+  CommandType,
+  OnCallback,
+  OnIdent,
+} from "./types";
 
 /**
  * Makes a router for storing resolve/reject for a message
@@ -23,10 +28,7 @@ const isRegExp = (v: OnIdent): v is RegExp => typeof v === "object";
 const satisfies = (id: OnIdent, matches: string): boolean =>
   isRegExp(id) ? matches.match(id) !== null : id === matches;
 export const makeCallbackRouter = () => {
-  const routes = new Map<
-    string,
-    { callback: OnCallback; ident: OnIdent }
-  >();
+  const routes = new Map<string, { callback: OnCallback; ident: OnIdent }>();
   return {
     get: (id: string): OnCallback[] => {
       return Array.from(routes.values())
@@ -40,10 +42,26 @@ export const makeCallbackRouter = () => {
   };
 };
 
-export const parseCallbackOptions = (msg: Record<string, any>): CallbackOptions | null => {
-  const sendResp = search(msg, "sendResp")
-  if (typeof sendResp === "boolean") return { sendResp }
-  return null
-}
+export const parseCallbackOptions = (
+  msg: Record<string, any>
+): CallbackOptions | null => {
+  const sendResp = search(msg, "sendResp");
+  if (typeof sendResp === "boolean") return { sendResp };
+  return null;
+};
 
 export const knownUSPVersions = ["1.0", "1.1", "1.2"] as const;
+
+export const parseSetArgs = (value, initialPath) => {
+  if (
+    Array.isArray(initialPath) &&
+    initialPath.every((v) => Array.isArray(v) && v.length === 2) &&
+    value === undefined
+  ) {
+    return [initialPath.map((v) => v[0]), initialPath.map((v) => v[1])];
+  }
+
+  const paths = Array.isArray(initialPath) ? initialPath : [initialPath];
+  const values = value ? (Array.isArray(value) ? value : [value]) : [];
+  return [paths, values];
+};
diff --git a/src/verify.ts b/src/verify.ts
new file mode 100644
index 0000000..b3363d6
--- /dev/null
+++ b/src/verify.ts
@@ -0,0 +1,148 @@
+import { defaultMissingErrorMessage } from "./errors";
+import { VerifyResult, CommandType, VerifierFunc } from "./types";
+
+const isPresent = (item) => item !== undefined;
+const isBool = (item) => typeof item === "boolean";
+const isNumber = (item) => typeof item === "number";
+const isString = (item) => typeof item === "string";
+const isArray = (item) => Array.isArray(item);
+const isTupleArray = (item) =>
+  Array.isArray(item) &&
+  item.length > 0 &&
+  item.every((it) => isArray(it) && it.length === 2);
+
+const checkGetOptions: VerifierFunc = ({ options }) => {
+  if (!isPresent(options)) return VerifyResult.NoError;
+  if (isPresent(options.raw) && !isBool(options.raw))
+    return VerifyResult.RawOptionNotBool;
+  if (isPresent(options.retainPath) && !isBool(options.retainPath))
+    return VerifyResult.RetainPathOptionNotBool;
+  if (isPresent(options.max_depth)) {
+    if (!isNumber(options.max_depth))
+      return VerifyResult.MaxDepthOptionNotNumber;
+    if (options.max_depth < 0) return VerifyResult.MaxDepthOptionIsNegative;
+  }
+
+  return VerifyResult.NoError;
+};
+
+const checkSetValue: VerifierFunc = ({ path, value }) => {
+  if (isTupleArray(path)) return VerifyResult.NoError
+  if (isArray(path)) {
+    if (!isPresent(value) && !isTupleArray(path))
+      return VerifyResult.ValueIsNotTupleArray;
+    if (!isArray(value)) return VerifyResult.ValueMustBeArray;
+    if (path.length !== value.length)
+      return VerifyResult.ValueAndPathArraysMustBeSameLength;
+  }
+  return value !== undefined && value !== null
+    ? VerifyResult.NoError
+    : VerifyResult.ValueIsMissing;
+};
+
+const checkAddValue: VerifierFunc = ({ path, value }) => {
+  if (isTupleArray(path)) return VerifyResult.NoError
+  if (isArray(path)) {
+    if (!isPresent(value)) {
+      if (!isTupleArray(path)) return VerifyResult.ValueIsNotTupleArray;
+      else return VerifyResult.NoError;
+    }
+    if (!isArray(value)) return VerifyResult.ValueMustBeArray;
+    if (path.length !== value.length)
+      return VerifyResult.ValueAndPathArraysMustBeSameLength;
+  }
+  return VerifyResult.NoError;
+};
+
+const checkSetOptions: VerifierFunc = ({ options }) => {
+  if (!isPresent(options)) return VerifyResult.NoError;
+  if (isPresent(options.raw) && !isBool(options.raw))
+    return VerifyResult.RawOptionNotBool;
+
+  return VerifyResult.NoError;
+};
+
+const checkDelOptions: VerifierFunc = ({ allowPartial }) => {
+  if (!isPresent(allowPartial)) return VerifyResult.NoError;
+  if (!isBool(allowPartial)) return VerifyResult.AllowPartialOptionNotBool;
+
+  return VerifyResult.NoError;
+};
+
+const checkSupportedDMOptions: VerifierFunc = ({ opts }) => {
+  if (!isPresent(opts)) return VerifyResult.NoError;
+  const allOptsAreBool = Object.values(opts).every(isBool);
+  return allOptsAreBool ? VerifyResult.NoError : VerifyResult.OptionsMustBeBool;
+};
+
+const checkInstancesOptions: VerifierFunc = ({ opts }) => {
+  if (!isPresent(opts)) return VerifyResult.NoError;
+  if (isPresent(opts.firstLevelOnly) && !isBool(opts.firstLevelOnly))
+    return VerifyResult.FirstLevelOnlyNotBool;
+  return VerifyResult.NoError;
+};
+
+const checkProto: VerifierFunc = ({ proto }) => {
+  if (isPresent(proto) && !isString(proto)) return VerifyResult.ProtoNotString;
+  return VerifyResult.NoError;
+};
+
+const checkSetPaths: VerifierFunc = ({ path, value }) => {
+  if (!isPresent(path)) return VerifyResult.PathsMissing
+  if (!isPresent(value) && isArray(path) && !isTupleArray(path))
+    return VerifyResult.ValueIsNotTupleArray;
+  return isString(path) ||
+      (isArray(path) &&
+        path.length > 0 &&
+        path.every((path) => typeof path === "string" || isArray(path)))
+    ? VerifyResult.NoError
+    : VerifyResult.PathsNotStringOrStringArray;
+};
+
+const checkPaths: VerifierFunc = ({
+  paths: initalPaths,
+  path: initialPath,
+}) => {
+  const paths = initalPaths || initialPath;
+  return !isPresent(paths)
+    ? VerifyResult.PathsMissing
+    : isString(paths) ||
+      (isArray(paths) &&
+        paths.length > 0 &&
+        paths.every((path) => typeof path === "string"))
+    ? VerifyResult.NoError
+    : VerifyResult.PathsNotStringOrStringArray;
+};
+
+const argsVerifiers: Partial<Record<CommandType, VerifierFunc[]>> = {
+  GET: [checkPaths, checkGetOptions],
+  SET: [checkSetPaths, checkSetValue, checkSetOptions],
+  ADD: [checkSetPaths, checkAddValue, checkSetOptions],
+  DELETE: [checkPaths, checkDelOptions],
+  OPERATE: [checkPaths],
+  GET_SUPPORTED_DM: [checkPaths, checkSupportedDMOptions],
+  GET_INSTANCES: [checkPaths, checkInstancesOptions],
+  GET_SUPPORTED_PROTO: [checkProto],
+};
+
+const translateError = (
+  errors: Record<VerifyResult, string>,
+  verifyResult: VerifyResult
+): Error => new Error(errors?.[verifyResult] || defaultMissingErrorMessage);
+
+export const makeVerify =
+  (errors: Record<VerifyResult, string>) =>
+  (command: CommandType, args: Record<string, any>): Error | null => {
+    const verifiers = argsVerifiers[command];
+    if (!verifiers) return null;
+
+    const results = verifiers.map((verifier) => verifier(args));
+    const finalResult =
+      results.find((value) => value !== VerifyResult.NoError) ||
+      VerifyResult.NoError;
+    return finalResult === VerifyResult.NoError
+      ? null
+      : translateError(errors, finalResult);
+  };
+
+export default makeVerify;
diff --git a/tests/integration/config.json b/tests/integration/config.json
index 43bab89..163c186 100644
--- a/tests/integration/config.json
+++ b/tests/integration/config.json
@@ -1,6 +1,6 @@
 {
   "ws": {
-    "host": "localhost",
+    "host": "192.168.2.1",
     "username": "admin",
     "password": "admin",
     "port": 9001,
diff --git a/tests/integration/errors.test.ts b/tests/integration/errors.test.ts
new file mode 100644
index 0000000..1c70ce1
--- /dev/null
+++ b/tests/integration/errors.test.ts
@@ -0,0 +1,157 @@
+import assert from "assert";
+import connect from "../../src";
+import { ConnectionOptions } from "../../src/types";
+import config from "./config.json";
+
+const expectError = async (func, errorMessage) => {
+  try {
+    await func();
+  } catch (error) {
+    assert.strictEqual(typeof error, "object");
+    assert.strictEqual(error.message, errorMessage);
+  }
+};
+
+describe("Test errors", function () {
+  this.timeout(10000);
+
+  let device: any = null;
+
+  before(async () => {
+    device = await connect(config.ws as ConnectionOptions);
+  });
+
+  after(async () => {
+    await device.disconnect();
+  });
+
+  it("no error", async () => {
+    const resp = await device.get([
+      "Device.Time.CurrentLocalTime",
+      "Device.Time.LocalTimeZone",
+    ]);
+    assert.strictEqual(Array.isArray(resp), true);
+  });
+
+  it("get errors", async () => {
+    await expectError(() => device.get(), "'paths' argument is missing");
+
+    await expectError(
+      () => device.get(3),
+      "'paths' argument has to be a string or a string array"
+    );
+    await expectError(
+      () => device.get([]),
+      "'paths' argument has to be a string or a string array"
+    );
+    await expectError(
+      () => device.get([1, 2, 3]),
+      "'paths' argument has to be a string or a string array"
+    );
+
+    await expectError(
+      () => device.get("Device.", { raw: 13 }),
+      "'raw' option has to be a boolean"
+    );
+
+    await expectError(
+      () => device.get("Device.", { retainPath: 13 }),
+      "'retainPath' option has to be a boolean"
+    );
+
+    await expectError(
+      () => device.get("Device.", { max_depth: false }),
+      "'max_depth' option has to be a positive number"
+    );
+    await expectError(
+      () => device.get("Device.", { max_depth: -3 }),
+      "'max_depth' option has to be a positive number"
+    );
+  });
+
+  it("set/add errors", async () => {
+    await expectError(
+      () => device.set("Device.NAT.PortMapping.1.Description"),
+      "'value' argument is required"
+    );
+
+    await expectError(
+      () => device.set(["Device.NAT.PortMapping.1.Description"], "Desc"),
+      "'value' argument must be an array, when 'path' is an array"
+    );
+    await expectError(
+      () => device.add(["Device.NAT.PortMapping."], { Description: "Desc" }),
+      "'value' argument must be an array, when 'path' is an array"
+    );
+
+    await expectError(
+      () =>
+        device.set(
+          ["Device.NAT.PortMapping.1.Description"],
+          ["Desc 1", "Desc 2"]
+        ),
+      "'path' and 'value' arrays must have the same length"
+    );
+    await expectError(
+      () =>
+        device.add(
+          ["Device.NAT.PortMapping."],
+          [{ Description: "Desc 1" }, { Description: "Desc 2" }]
+        ),
+      "'path' and 'value' arrays must have the same length"
+    );
+
+    await expectError(
+      () =>
+        device.set([
+          ["Device.NAT.PortMapping.1.Description", "desc 1", "desc 2"],
+        ]),
+      "'path' argument must be an array of tuples"
+    );
+    await expectError(
+      () =>
+        device.add([
+          [
+            "Device.NAT.PortMapping.",
+            { Description: "Desc 1" },
+            { Description: "Desc 2" },
+          ],
+        ]),
+      "'path' argument must be an array of tuples"
+    );
+  });
+
+  it("delete errors", async () => {
+    await expectError(
+      () => device.del("Device.NAT.PortMapping.1.", 13),
+      "'allowPartial' option has to be a boolean"
+    );
+  });
+
+  it("supportedDM errors", async () => {
+    await expectError(
+      () =>
+        device.supportedDM("Device.NAT.PortMapping.1.", {
+          firstLevelOnly: true,
+          returnCommands: true,
+          returnEvents: true,
+          returnParams: 13,
+        }),
+      "all options have to be boolean"
+    );
+  });
+
+  it("instances errors", async () => {
+    await expectError(
+      () => device.instances("Device.NAT.PortMapping.", 13),
+      "'firstLevelOnly' has to be a boolean"
+    );
+  });
+
+  it("proto errors", async () => {
+    await expectError(
+      () => device.supportedProto(13),
+      "'proto' argument has to be a string"
+    );
+  });
+});
diff --git a/tests/integration/index.test.ts b/tests/integration/index.test.ts
index ff87274..36b2cfc 100644
--- a/tests/integration/index.test.ts
+++ b/tests/integration/index.test.ts
@@ -97,6 +97,23 @@ describe("Test general API", function () {
     await device.set("Device.WiFi.Radio.1.Alias", alias);
   });
 
+  it("set multiple with value", async () => {
+    await device.set(
+      [
+        "Device.NAT.PortMapping.1.Description",
+        "Device.NAT.PortMapping.2.Description",
+      ],
+      ["desc 1", "desc 2"]
+    );
+  });
+
+  it("set multiple with value (alternate usage)", async () => {
+    await device.set([
+      ["Device.NAT.PortMapping.1.Description", "desc 1"],
+      ["Device.NAT.PortMapping.2.Description", "desc 2"],
+    ]);
+  });
+
   it("set with object", async () => {
     const Alias = await device.get("Device.WiFi.Radio.1.Alias");
     await device.set("Device.WiFi.Radio.1.", { Alias });
-- 
GitLab