fratzinger/feathers-utils

View on GitHub
src/hooks/setData.ts

Summary

Maintainability
D
2 days
Test Coverage
A
99%
import _get from "lodash/get.js";
import _set from "lodash/set.js";
import _has from "lodash/has.js";

import { Forbidden } from "@feathersjs/errors";
import { getItemsIsArray, shouldSkip } from "../utils";

import type { HookContext } from "@feathersjs/feathers";
import type { PropertyPath } from "lodash";
import type { PredicateWithContext } from "../types";
import { toJSON } from "../utils/toJSON";

export interface HookSetDataOptions {
  allowUndefined?: boolean;
  overwrite?: boolean | PredicateWithContext;
}

const defaultOptions: Required<HookSetDataOptions> = {
  allowUndefined: false,
  overwrite: true,
};

/**
 * hook to set properties on `context.result` (if existent) or `context.data` (otherwise)
 */
export function setData<H extends HookContext = HookContext>(
  from: PropertyPath,
  to: PropertyPath,
  _options?: HookSetDataOptions,
) {
  const options: Required<HookSetDataOptions> = {
    ...defaultOptions,
    ..._options,
  };
  return (context: H) => {
    if (shouldSkip("setData", context)) {
      return context;
    }

    const { items } = getItemsIsArray(context);

    const contextJson = toJSON(context);

    if (!_has(contextJson, from)) {
      if (!context.params?.provider || options.allowUndefined === true) {
        return context;
      }

      if (
        !options.overwrite &&
        items.every((item: Record<string, unknown>) => _has(item, to))
      ) {
        return context;
      }

      throw new Forbidden(`Expected field ${from.toString()} not available`);
    }

    const val = _get(contextJson, from);

    items.forEach((item: Record<string, unknown>) => {
      let overwrite: boolean;
      if (typeof options.overwrite === "function") {
        overwrite = options.overwrite(item, context);
      } else {
        overwrite = options.overwrite;
      }

      if (!overwrite && _has(item, to)) {
        return;
      }

      _set(item, to, val);
    });

    return context;
  };
}

if (import.meta.vitest) {
  const { describe, it, assert, expect } = import.meta.vitest;

  it("sets userId for single item", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          params: {
            user: {
              id: 1,
            },
          },
        } as HookContext;

        const dataOrResult = type === "before" ? "data" : "result";
        context[dataOrResult] = {};

        const result = setData("params.user.id", "userId")(context);
        assert.strictEqual(
          result[dataOrResult].userId,
          1,
          `'${type}/${method}': ${dataOrResult} has 'userId:1'`,
        );
      });
    });
  });

  it("overwrites userId for single item", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          params: {
            user: {
              id: 1,
            },
          },
        } as HookContext;

        const dataOrResult = type === "before" ? "data" : "result";
        context[dataOrResult] = { userId: 2 };

        const result = setData("params.user.id", "userId")(context);
        assert.strictEqual(
          result[dataOrResult].userId,
          1,
          `'${type}/${method}': ${dataOrResult} has 'userId:1'`,
        );
      });
    });
  });

  it("sets userId for multiple items", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          params: {
            user: {
              id: 1,
            },
          },
        } as HookContext;

        const dataOrResult = type === "before" ? "data" : "result";
        context[dataOrResult] = [{}, {}, {}];

        const result = setData("params.user.id", "userId")(context);
        result[dataOrResult].forEach((item) => {
          assert.strictEqual(
            item.userId,
            1,
            `'${type}/${method}': ${dataOrResult} has 'userId:1'`,
          );
        });
      });
    });
  });

  it("overwrites userId for multiple items", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          params: {
            user: {
              id: 1,
            },
          },
        } as HookContext;

        const dataOrResult = type === "before" ? "data" : "result";
        context[dataOrResult] = [{ userId: 2 }, {}, { userId: "abc" }];

        const result = setData("params.user.id", "userId")(context);
        result[dataOrResult].forEach((item) => {
          assert.strictEqual(
            item.userId,
            1,
            `'${type}/${method}': ${dataOrResult} has 'userId:1'`,
          );
        });
      });
    });
  });

  it("does not change createdById if 'params.user.id' is not provided", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          params: {},
        } as HookContext;

        const dataOrResult = type === "before" ? "data" : "result";
        context[dataOrResult] = { userId: 2 };

        const result = setData("params.user.id", "userId")(context);

        assert.strictEqual(
          result[dataOrResult].userId,
          2,
          `'${type}/${method}': ${dataOrResult} has 'userId:2'`,
        );
      });
    });
  });

  it("throws if 'external' is set and context.user.id is undefined", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          params: {
            provider: "socket.io",
          },
        } as HookContext;

        const dataOrResult = type === "before" ? "data" : "result";
        context[dataOrResult] = {};

        expect(() => setData("params.user.id", "userId")(context)).toThrow(
          Forbidden,
        );
      });
    });
  });

  it("passes if 'external' and 'allowUndefined: true'", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          provider: "socket.io",
          params: {},
          data: {},
        } as unknown as HookContext;

        assert.doesNotThrow(
          () =>
            setData("params.user.id", "userId", { allowUndefined: true })(
              context,
            ),
          `'${type}/${method}': passes`,
        );
      });
    });
  });

  it("passes if 'external' is set and context.user.id is undefined but overwrite: false", function () {
    const methodsByType = {
      before: ["create", "update", "patch", "remove"],
      after: ["find", "get", "create", "update", "patch", "remove"],
    };
    Object.keys(methodsByType).forEach((type) => {
      methodsByType[type].forEach((method) => {
        const context = {
          method,
          type,
          params: {
            provider: "socket.io",
          },
        } as unknown as HookContext;

        const dataOrResult = type === "before" ? "data" : "result";
        context[dataOrResult] = { userId: 1 };

        assert.doesNotThrow(
          () =>
            setData("params.user.id", "userId", { overwrite: false })(context),
          `'${type}/${method}': passes`,
        );
      });
    });
  });

  describe("overwrite: false", function () {
    it("sets userId for single item", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = {};

          const result = setData("params.user.id", "userId", {
            overwrite: false,
          })(context);
          assert.strictEqual(
            result[dataOrResult].userId,
            1,
            `'${type}/${method}': ${dataOrResult} has 'userId:1'`,
          );
        });
      });
    });

    it("does not overwrite userId for single item", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = { userId: 2 };

          const result = setData("params.user.id", "userId", {
            overwrite: false,
          })(context);
          assert.strictEqual(
            result[dataOrResult].userId,
            2,
            `'${type}/${method}': ${dataOrResult} has 'userId:2'`,
          );
        });
      });
    });

    it("sets userId for multiple items", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = [{}, {}, {}];

          const result = setData("params.user.id", "userId", {
            overwrite: false,
          })(context);
          result[dataOrResult].forEach((item) => {
            assert.strictEqual(
              item.userId,
              1,
              `${type}/${method}': ${dataOrResult} has 'userId:1'`,
            );
          });
        });
      });
    });

    it("overwrites userId for multiple items", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = [{ userId: 0 }, {}, { userId: 2 }];

          const result = setData("params.user.id", "userId", {
            overwrite: false,
          })(context);
          result[dataOrResult].forEach((item, i) => {
            assert.strictEqual(
              item.userId,
              i,
              `${type}/${method}': ${dataOrResult} has 'userId:${i}`,
            );
          });
        });
      });
    });
  });

  describe("overwrite: predicate", function () {
    it("overwrites userId for multiple items per predicate", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = [{ userId: 2 }, {}, { userId: "abc" }];

          const result = setData("params.user.id", "userId", {
            overwrite: () => true,
          })(context);
          result[dataOrResult].forEach((item) => {
            assert.strictEqual(
              item.userId,
              1,
              `'${type}/${method}': ${dataOrResult} has 'userId:1'`,
            );
          });
        });
      });
    });

    it("does not overwrite userId for single item by predicate", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = { userId: 2 };

          const result = setData("params.user.id", "userId", {
            overwrite: (item) => item.userId == null,
          })(context);
          assert.strictEqual(
            result[dataOrResult].userId,
            2,
            `'${type}/${method}': ${dataOrResult} has 'userId:2'`,
          );
        });
      });
    });

    it("predicate based on context", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = { userId: 2 };

          const result = setData("params.user.id", "userId", {
            overwrite: (item, context) => context.type === "before",
          })(context);
          if (type === "before") {
            assert.strictEqual(
              result[dataOrResult].userId,
              1,
              `'${type}/${method}': ${dataOrResult} has 'userId:1'`,
            );
          } else {
            assert.strictEqual(
              result[dataOrResult].userId,
              2,
              `'${type}/${method}': ${dataOrResult} has 'userId:2'`,
            );
          }
        });
      });
    });

    it("overwrites userId for multiple items by predicate", function () {
      const methodsByType = {
        before: ["create", "update", "patch", "remove"],
        after: ["find", "get", "create", "update", "patch", "remove"],
      };
      Object.keys(methodsByType).forEach((type) => {
        methodsByType[type].forEach((method) => {
          const context = {
            method,
            type,
            params: {
              user: {
                id: 1,
              },
            },
          } as unknown as HookContext;

          const dataOrResult = type === "before" ? "data" : "result";
          context[dataOrResult] = [{ userId: 0 }, {}, { userId: 2 }];

          const result = setData("params.user.id", "userId", {
            overwrite: (item) => item.userId == null,
          })(context);
          result[dataOrResult].forEach((item, i) => {
            assert.strictEqual(
              item.userId,
              i,
              `${type}/${method}': ${dataOrResult} has 'userId:${i}`,
            );
          });
        });
      });
    });
  });
}