@grouparoo/core
Version:
The Grouparoo Core
583 lines (477 loc) • 16.9 kB
text/typescript
import { helper } from "@grouparoo/spec-helper";
import { api, redis, utils } from "actionhero";
import { App, Option, plugin, PluginOptionType } from "../../../src";
import { ObfuscatedOptionString } from "../../../src/modules/optionHelper";
describe("models/app", () => {
helper.grouparooTestServer({ truncate: true, enableTestPlugin: true });
test("an app can be created and options added", async () => {
const app = new App({
name: "test app",
type: "test-plugin-app",
});
await app.save();
expect(app.id.length).toBe(40);
expect(app.createdAt).toBeTruthy();
expect(app.updatedAt).toBeTruthy();
await app.setOptions({ fileId: "abc123" });
const options = await app.getOptions();
expect(options).toEqual({ fileId: "abc123" });
await app.destroy();
});
test("apps start in the draft state", async () => {
const app = await App.create({
name: "test app",
type: "test-plugin-app",
});
expect(app.state).toBe("draft");
await app.destroy();
});
test("a new app will have a '' name", async () => {
const app = await App.create({
type: "test-plugin-app",
});
expect(app.name).toBe("");
await app.destroy();
});
test("draft apps can share the same name, but not with ready apps", async () => {
const appOne = await App.create({ type: "test-plugin-app" });
const appTwo = await App.create({ type: "test-plugin-app" });
expect(appOne.name).toBe("");
expect(appTwo.name).toBe("");
await appOne.update({ name: "name" });
await appOne.setOptions({ fileId: "abc123" });
await appOne.update({ state: "ready" });
await expect(appTwo.update({ name: "name" })).rejects.toThrow(
/name "name" is already in use/
);
await appOne.destroy();
await appTwo.destroy();
});
test("deleted apps can share the same name, but not with ready apps", async () => {
const appOne = await App.create({
type: "test-plugin-app",
});
const appTwo = await App.create({
type: "test-plugin-app",
});
const appThree = await App.create({
type: "test-plugin-app",
});
await appOne.setOptions({ fileId: "abc123" });
await appOne.update({ name: "1", state: "ready" });
await appTwo.setOptions({ fileId: "abc123" });
await appTwo.update({ name: "2", state: "deleted" });
await appThree.setOptions({ fileId: "abc123" });
await appThree.update({ name: "3", state: "deleted" });
expect(appOne.name).toBe("1");
expect(appOne.state).toBe("ready");
expect(appTwo.name).toBe("2");
expect(appTwo.state).toBe("deleted");
expect(appThree.name).toBe("3");
expect(appThree.state).toBe("deleted");
await appThree.update({ name: "2" });
await expect(appTwo.update({ name: "1" })).rejects.toThrow(
/name "1" is already in use/
);
await appOne.update({ name: "2" });
await appOne.destroy();
await appTwo.destroy();
await appThree.destroy();
});
test("deleting an app removes the appOptions", async () => {
const app = new App({
name: "test app",
type: "test-plugin-app",
});
await app.save();
await app.setOptions({ fileId: "abc123" });
let count = await Option.count({ where: { ownerId: app.id } });
expect(count).toBe(1);
await app.destroy();
count = await Option.count({ where: { ownerId: app.id } });
expect(count).toBe(0);
});
test("deleting an app does not delete options for other models with the same id", async () => {
const app = new App({
name: "test app",
type: "test-plugin-app",
});
await app.save();
await app.setOptions({ fileId: "abc123" });
const foreignOption = await Option.create({
ownerId: app.id,
ownerType: "other",
key: "someKey",
value: "someValue",
type: "string",
});
let count = await Option.count({
where: { ownerId: app.id },
});
expect(count).toBe(2);
await app.destroy();
const options = await Option.findAll({
where: { ownerId: app.id },
});
expect(options.length).toBe(1);
expect(options[0].ownerType).toBe("other");
expect(options[0].key).toBe("someKey");
await foreignOption.destroy();
});
test("apps can determine if they will provide a source or destination", async () => {
const app = await App.create({
name: "test log app",
type: "test-plugin-app",
});
const provides = app.provides();
expect(provides).toEqual({ source: true, destination: true });
await app.destroy();
});
describe("options from environment variables", () => {
beforeAll(() => {
process.env.GROUPAROO_OPTION__APP__TEST_OPTION = "abc123";
});
test("options can be set from an environment variable but not stored in the database", async () => {
const app = await App.create({
name: "test app",
type: "test-plugin-app",
});
await app.setOptions({ fileId: "TEST_OPTION" });
const options = await app.getOptions();
expect(options.fileId).toBe("abc123");
const option = await Option.findOne({
where: { ownerId: app.id, key: "fileId" },
});
expect(option.value).toBe("TEST_OPTION");
await app.destroy();
});
test("apps can be tested with environment variables", async () => {
const app = await App.create({
name: "test app",
type: "test-plugin-app",
});
const { success, error, message } = await app.test({
fileId: "TEST_OPTION",
});
expect(success).toBe(true);
expect(error).toBeUndefined();
expect(message).toBe("OK");
await app.destroy();
});
afterAll(() => {
process.env.GROUPAROO_OPTION__APP__TEST_OPTION = undefined;
});
});
describe("validations", () => {
test("apps must be of a type defined by a plugin", async () => {
const app = new App({
name: "test-plugin-no-app",
type: "oh no",
options: {},
});
await expect(app.save()).rejects.toThrow(
/Cannot find a \"oh no\" app available within the installed plugins. Current apps installed are:/
);
});
test("__options only includes options for apps", async () => {
const app = await App.create({
id: "myAppId",
name: "test app",
type: "test-plugin-app",
});
await Option.create({
ownerId: app.id,
ownerType: "app",
key: "fileId",
value: "users",
type: "string",
});
await Option.create({
ownerId: app.id,
ownerType: "source",
key: "someOtherProperty",
value: "someValue",
type: "string",
});
const options = await app.$get("__options");
expect(options.length).toBe(1);
expect(options[0].ownerType).toBe("app");
expect(options[0].key).toBe("fileId");
await app.destroy();
});
test("adding the wrong options for the app produces an error", async () => {
const app = await App.create({
name: "test app",
type: "test-plugin-app",
});
await expect(app.setOptions({ thing: "stuff" })).rejects.toThrow(
/fileId is required for a app of type test-plugin-app/
);
await expect(
app.setOptions({ fileId: "abc123", otherThing: "123" })
).rejects.toThrow(
/otherThing is not an option for a test-plugin-app app/
);
await expect(
app.setOptions({ fileId: "abc123", environment: "my house" })
).rejects.toThrow(
/"my house" is not a valid value for test-plugin-app app option "environment"/
);
await app.destroy();
});
test("an app cannot be changed to to the ready state if there are missing required options", async () => {
const app = await App.create({
name: "test app",
type: "test-plugin-app",
});
await expect(app.update({ state: "ready" })).rejects.toThrow();
await app.destroy();
});
test("an app that is ready cannot move back to draft", async () => {
const app = await App.create({
name: "test app",
type: "test-plugin-app",
});
await app.setOptions({ fileId: "abc123" });
await app.update({ state: "ready" });
expect(app.state).toBe("ready");
await expect(app.update({ state: "draft" })).rejects.toThrow(
/cannot transition app state from ready to draft/
);
await app.destroy();
});
test("an app cannot be created in the ready state with missing required options", async () => {
const app = App.build({
type: "test-plugin-app",
state: "ready",
});
await expect(app.save()).rejects.toThrow(/fileId is required/);
});
test("an app with a source cannot be deleted", async () => {
const app = await App.create({
name: "test app",
type: "test-plugin-app",
});
await app.setOptions({ fileId: "abc" });
await app.update({ state: "ready" });
const source = await helper.factories.source(app);
await expect(app.destroy()).rejects.toThrow(
/cannot delete this app, source .* relies on it/
);
// doesn't throw
await source.destroy();
await app.destroy();
});
});
describe("with plugin", () => {
let app: App;
let testCounter = 0;
let recordPropertyCount = 0;
let parallelism = Infinity;
let appOptionsReturnType: PluginOptionType = "list";
beforeAll(async () => {
plugin.registerPlugin({
name: "test-plugin",
icon: "/path/to/icon.svg",
apps: [
{
name: "test-template-app",
displayName: "test-template-app",
options: [
{ key: "test_key", type: "text", required: true },
{ key: "test_options", type: "list", required: false },
{ key: "password", type: "password", required: false },
],
methods: {
test: async () => {
testCounter++;
return { success: true };
},
appOptions: async () => {
return {
test_options: {
type: appOptionsReturnType,
options: ["a", "b"],
},
};
},
parallelism: async () => {
return parallelism;
},
},
},
],
connections: [
{
name: "import-from-test-template-app",
displayName: "import-from-test-template-app",
description: "a test app connection",
supportIncrementalSchedule: true,
apps: ["test-template-app"],
direction: "import" as "import",
options: [],
methods: {
importRecords: async () => {
return {
importsCount: 0,
highWaterMark: { col: "0" },
sourceOffset: 0,
};
},
recordProperty: async ({ app, property, record }) => {
recordPropertyCount++;
return ["test@example.com"];
},
},
},
],
});
app = await App.create({
name: "test app",
type: "test-template-app",
options: { test_key: true },
});
});
test("plugins can provide icons", async () => {
const apiData = await app.apiData();
expect(apiData.icon).toBe("/path/to/icon.svg");
});
test("apiData returns ObfuscatedOptionString for any appOption of type 'password'", async () => {
await app.setOptions({ password: "SECRET", test_key: "something" });
const apiData = await app.apiData();
expect(apiData.options).toEqual({
test_key: "something",
password: ObfuscatedOptionString,
});
});
test("it can return the appOptions from the plugin", async () => {
// original
const optionsA = await app.appOptions();
expect(optionsA).toEqual({
test_key: { type: "text" }, // statically defined
test_options: { type: "list", options: ["a", "b"] }, // dynamically defined
password: { type: "password" }, // statically defined
});
// override
appOptionsReturnType = "pending";
const optionsB = await app.appOptions();
expect(optionsB).toEqual({
test_key: { type: "text" }, // statically defined
test_options: { type: "pending", options: ["a", "b"] }, // dynamically defined
password: { type: "password" }, // statically defined
});
});
test("it will replace obfuscated app options for the plugin's test method", async () => {
await app.setOptions({ password: "SECRET", test_key: "something" });
const plugin = api.plugins.plugins.find(
(plugin) => plugin.name === "test-plugin"
);
const spy = jest.spyOn(plugin.apps[0].methods, "test");
await app.test({
test_key: "something",
password: ObfuscatedOptionString,
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
appOptions: {
test_key: "something",
password: "SECRET",
},
})
);
expect(spy).not.toHaveBeenCalledWith(
expect.objectContaining({
appOptions: {
password: ObfuscatedOptionString,
},
})
);
});
test("it will disconnect app after changing options", async () => {
const spy = jest.spyOn(App, "disconnect");
await app.update({ state: "ready" });
// options haven't changed, keep connection
await app.setOptions({ password: "SECRET", test_key: "something" });
expect(App.disconnect).toHaveBeenCalledTimes(0);
// options changed, disconnect
await app.setOptions({
password: "SECRET",
test_key: "some other thing",
});
expect(App.disconnect).toHaveBeenCalledTimes(1);
expect(App.disconnect).toHaveBeenCalledWith(app.id);
spy.mockRestore();
});
test("it will filter empty app options for the plugin's test method", async () => {
const plugin = api.plugins.plugins.find(
(plugin) => plugin.name === "test-plugin"
);
const spy = jest.spyOn(plugin.apps[0].methods, "test");
await app.test({
test_key: "my_key",
password: "",
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
appOptions: {
test_key: "my_key",
},
})
);
expect(spy).not.toHaveBeenCalledWith(
expect.objectContaining({
appOptions: {
password: "",
},
})
);
});
test("it can run a plugin's test method", async () => {
testCounter = 0;
const { error, success } = await app.test();
expect(error).toBeUndefined();
expect(success).toBe(true);
expect(testCounter).toBe(1);
});
test("apps can return their parallelism", async () => {
expect(await app.getParallelism()).toEqual(Infinity);
});
test("apps can checkAndUpdateParallelism and see when the limit would be exceeded", async () => {
parallelism = 3;
const A = await app.checkAndUpdateParallelism("incr");
const B = await app.checkAndUpdateParallelism("incr");
const C = await app.checkAndUpdateParallelism("incr");
const D = await app.checkAndUpdateParallelism("incr");
expect(A).toBe(true);
expect(B).toBe(true);
expect(C).toBe(true);
expect(D).toBe(false);
await app.checkAndUpdateParallelism("decr");
const E = await app.checkAndUpdateParallelism("incr");
expect(E).toBe(true);
parallelism = Infinity;
});
test("deleting an app removes the parallelism key", async () => {
const key = app.parallelismKey();
const redis = api.redis.clients.client;
expect(await redis.exists(key)).toBe(1);
await app.destroy();
expect(await redis.exists(key)).toBe(0);
});
});
describe("RPC methods", () => {
beforeAll(() => {
jest.spyOn(App, "disconnect");
});
test("api.rpc.app.disconnect calls App.disconnect", async () => {
await redis.doCluster("api.rpc.app.disconnect");
await utils.sleep(1000);
expect(App.disconnect).toHaveBeenCalledWith(undefined);
});
test("api.rpc.app.disconnect can be called with an App id", async () => {
await redis.doCluster("api.rpc.app.disconnect", ["some_app_id"]);
await utils.sleep(1000);
expect(App.disconnect).toHaveBeenCalledWith("some_app_id");
});
});
});