import { helper } from "@grouparoo/spec-helper";
import { cache } from "actionhero";
import { DestinationOps } from "../../../src/modules/ops/destination";
import {
  App,
  Destination,
  DestinationGroupMembership,
  Export,
  Group,
  GrouparooModel,
  GroupMember,
  Mapping,
  Option,
  plugin,
  Run,
} from "../../../src";
import { ExportOps } from "../../../src/modules/ops/export";

describe("models/destination", () => {
  helper.grouparooTestServer({ truncate: true, enableTestPlugin: true });

  describe("with apps", () => {
    let model: GrouparooModel;
    let app: App;
    let destination: Destination;

    beforeAll(async () => {
      ({ model } = await helper.factories.properties());
      app = await helper.factories.app();
    });

    afterEach(async () => {
      if (destination) {
        await destination.destroy();
      }
    });

    test("a destination can be created, and it can find the related app", async () => {
      destination = await Destination.create({
        name: "test destination",
        type: "test-plugin-export",
        syncMode: "sync",
        appId: app.id,
        modelId: model.id,
      });

      expect(destination.id.length).toBe(40);
      expect(destination.createdAt).toBeTruthy();
      expect(destination.updatedAt).toBeTruthy();

      const options = await destination.getOptions();
      expect(options).toEqual({});

      const _app = await destination.$get("app");
      expect(_app.id).toBe(app.id);
    });

    test("a new destination will have a '' name", async () => {
      destination = await Destination.create({
        type: "test-plugin-export",
        appId: app.id,
        modelId: model.id,
      });

      expect(destination.name).toBe("");

      await destination.destroy();
    });

    test("draft destination can share the same name, but not with ready destination", async () => {
      const destinationOne = await Destination.create({
        type: "test-plugin-export",
        syncMode: "sync",
        appId: app.id,
        modelId: model.id,
      });

      const otherApp = await helper.factories.app();
      const destinationTwo = await Destination.create({
        type: "test-plugin-export",
        syncMode: "sync",
        appId: otherApp.id,
        modelId: model.id,
      });

      expect(destinationOne.name).toBe("");
      expect(destinationTwo.name).toBe("");

      await destinationOne.update({ name: "name" });
      await destinationOne.setOptions({ table: "users_out" });
      await destinationOne.update({ state: "ready" });

      await expect(destinationTwo.update({ name: "name" })).rejects.toThrow(
        /name "name" is already in use/
      );

      await destinationOne.destroy();
      await destinationTwo.destroy();
      await otherApp.destroy();
    });

    test("deleted destination can share the same name, but not with ready destination", async () => {
      const destinationOne = await Destination.create({
        type: "test-plugin-export",
        syncMode: "sync",
        appId: app.id,
        modelId: model.id,
      });

      const otherApp = await helper.factories.app();
      const destinationTwo = await Destination.create({
        type: "test-plugin-export",
        syncMode: "sync",
        appId: otherApp.id,
        modelId: model.id,
      });

      const otherOtherApp = await helper.factories.app();
      const destinationThree = await Destination.create({
        type: "test-plugin-export",
        syncMode: "sync",
        appId: otherOtherApp.id,
        modelId: model.id,
      });

      expect(destinationOne.name).toBe("");
      expect(destinationTwo.name).toBe("");
      expect(destinationThree.name).toBe("");

      await destinationOne.setOptions({ table: "users_out" });
      await destinationOne.update({ state: "ready" });

      await destinationTwo.update({ state: "deleted", name: "asdf" });
      await destinationThree.update({ state: "deleted", name: "asdf" });
      await destinationOne.update({ name: "asdf" });

      await expect(destinationTwo.update({ name: "asdf" })).rejects.toThrow(
        /name "asdf" is already in use/
      );

      await destinationOne.destroy();
      await destinationTwo.destroy();
      await otherApp.destroy();
      await destinationThree.destroy();
      await otherOtherApp.destroy();
    });

    test("a destination cannot be created with a deleted state model", async () => {
      await model.update({ state: "deleted" });

      const destination = new Destination({
        type: "test-plugin-export",
        syncMode: "sync",
        appId: app.id,
        modelId: model.id,
      });

      await expect(destination.save()).rejects.toThrow(
        /cannot find ready model with id/
      );

      await model.update({ state: "ready" });
    });

    test("a deleted destination can be saved with a deleted state model", async () => {
      const destination = new Destination({
        type: "test-plugin-export",
        syncMode: "sync",
        appId: app.id,
        modelId: model.id,
      });

      await destination.save();
      await model.update({ state: "deleted" });
      await destination.update({ name: "abc", state: "deleted" });
      expect(destination.name).toBe("abc");
      await model.update({ state: "ready" });
    });

    test("deleting a destination deletes related models", async () => {
      const group = await helper.factories.group();
      destination = await Destination.create({
        name: "bye destination",
        type: "test-plugin-export",
        appId: app.id,
        modelId: model.id,
      });

      await destination.setOptions({ table: "users_out" });
      await destination.setMapping({ "primary-id": "userId" });
      await destination.updateTracking("group", group.id);
      const destinationGroupMemberships: Record<string, string> = {};
      destinationGroupMemberships[group.id] = "remote-tag";
      await destination.setDestinationGroupMemberships(
        destinationGroupMemberships
      );

      await destination.updateTracking("none");
      await destination.destroy();

      let count = await DestinationGroupMembership.count({
        where: { destinationId: destination.id },
      });
      expect(count).toBe(0);
      count = await Option.count({
        where: { ownerId: destination.id },
      });
      expect(count).toBe(0);
      count = await Mapping.count({
        where: { ownerId: destination.id },
      });
      expect(count).toBe(0);

      await group.destroy();
    });

    test("deleting a destination sets related export's destinationId to null", async () => {
      destination = await Destination.create({
        name: "bye destination",
        type: "test-plugin-export",
        appId: app.id,
        modelId: model.id,
      });

      const _export = await helper.factories.export(null, destination);
      await ExportOps.completeBatch([_export]);
      expect(_export.destinationId).toBe(destination.id);

      await destination.destroy();

      await _export.reload();
      expect(_export.destinationId).toBeNull();
    });

    test("destinations require a valid modelId", async () => {
      expect(
        Destination.create({
          name: "bye destination",
          type: "test-plugin-export",
          appId: app.id,
          modelId: "foo",
        })
      ).rejects.toThrow(/cannot find model with id "foo"/);
    });

    test("destinations cannot change models", async () => {
      destination = await Destination.create({
        name: "bye destination",
        type: "test-plugin-export",
        appId: app.id,
        modelId: model.id,
      });
      await expect(destination.update({ modelId: "foo" })).rejects.toThrow(
        /cannot change models/
      );

      await destination.destroy();
    });

    test("deleting a destination does not delete options for other models with the same id", async () => {
      destination = await Destination.create({
        name: "some destination",
        type: "test-plugin-export",
        appId: app.id,
        modelId: model.id,
      });

      await destination.setOptions({ table: "users_out" });

      const foreignOption = await Option.create({
        ownerId: destination.id,
        ownerType: "other",
        key: "someKey",
        value: "someValue",
        type: "string",
      });

      let count = await Option.count({
        where: { ownerId: destination.id },
      });
      expect(count).toBe(2);

      await destination.destroy();
      const options = await Option.findAll({
        where: { ownerId: destination.id },
      });
      expect(options.length).toBe(1);
      expect(options[0].ownerType).toBe("other");
      expect(options[0].key).toBe("someKey");

      await foreignOption.destroy();
    });

    test("destinations can retrieve related export totals", async () => {
      destination = await Destination.create({
        name: "bye destination",
        type: "test-plugin-export",
        appId: app.id,
        modelId: model.id,
      });

      const record = await helper.factories.record();
      const _export = await Export.create({
        destinationId: destination.id,
        recordId: record.id,
        oldRecordProperties: {},
        newRecordProperties: {},
        oldGroups: [],
        newGroups: [],
      });

      const totals = await destination.getExportTotals();
      expect(totals).toEqual({
        canceled: 0,
        complete: 0,
        draft: 0,
        failed: 0,
        pending: 1,
        processing: 0,
      });

      await _export.destroy();
      await destination.destroy();
      await record.destroy();
    });

    test.each(["deleted", "ready"])(
      "a destination can get options from a connection with a %p app",
      async (appState) => {
        await app.update({ state: appState });
        const connectionOptions =
          await destination.destinationConnectionOptions();
        expect(connectionOptions).toEqual({
          tableWithOptions: {
            type: "list",
            options: ["users_out", "users", "groups"],
          },
        });
      }
    );

    test("partial options will be passed to destinationConnectionOptions", async () => {
      const connectionOptions = await destination.destinationConnectionOptions({
        options: "true",
      });
      expect(connectionOptions).toEqual({
        tableWithOptions: {
          type: "list",
          options: ["users_out", "users", "groups"],
        },
        receivedOptions: { type: "text", options: ["true"] },
      });
    });

    describe("options trigger runs", () => {
      const newTable = "users_out";
      let destination: Destination;
      let group: Group;
      let run: Run;

      beforeAll(async () => {
        destination = await Destination.create({
          name: "incoming destination",
          type: "test-plugin-export",
          appId: app.id,
          modelId: model.id,
        });

        group = await helper.factories.group();
        await destination.updateTracking("group", group.id);
      });

      afterAll(async () => {
        await destination.updateTracking("none");
        await destination.destroy();
        await group.destroy();
      });

      test("setting a destination's options triggers a group run", async () => {
        await destination.setOptions({ table: newTable });
        const runningRuns = await Run.findAll({
          where: { state: "running", creatorType: "group" },
        });
        expect(runningRuns.length).toBe(1);
        expect(runningRuns[0].destinationId).toBe(destination.id);
        run = runningRuns[0];
      });

      test("re-setting options with the same value will not trigger a group run ", async () => {
        await destination.setOptions({ table: newTable });
        await destination.setOptions({ table: newTable });

        await destination.setOptions({ table: newTable });
        const runningRuns = await Run.findAll({
          where: { state: "running", creatorType: "group" },
        });
        expect(runningRuns.length).toEqual(1);
        expect(runningRuns[0].id).toEqual(run.id);
      });
    });

    describe("options from environment variables", () => {
      beforeAll(() => {
        process.env.GROUPAROO_OPTION__DESTINATION__TEST_OPTION = "users_out";
      });

      test("options can be set from an environment variable but not stored in the database", async () => {
        const destination = await Destination.create({
          name: "incoming destination",
          type: "test-plugin-export",
          appId: app.id,
          modelId: model.id,
        });

        await destination.setOptions({ table: "TEST_OPTION" });
        const options = await destination.getOptions();
        expect(options.table).toBe("users_out");

        const option = await Option.findOne({
          where: { ownerId: destination.id, key: "table" },
        });
        expect(option.value).toBe("TEST_OPTION");

        await destination.destroy();
      });

      afterAll(() => {
        process.env.GROUPAROO_OPTION__DESTINATION__TEST_OPTION = undefined;
      });
    });

    describe("validations", () => {
      describe("with additional plugin", () => {
        beforeAll(async () => {
          plugin.registerPlugin({
            name: "test-other-plugin",
            apps: [
              {
                name: "test-other-plugin-app",
                displayName: "test-other-plugin-app",
                options: [],
                methods: {
                  test: async () => {
                    return { success: true };
                  },
                },
              },
            ],
          });
        });

        test("the app must be of compatible type", async () => {
          const otherApp = await App.create({
            name: "my other app",
            type: "test-other-plugin-app",
          });
          await otherApp.update({ state: "ready" });

          await expect(
            Destination.create({
              type: "test-plugin-import",
              name: "test destination",
              appId: otherApp.id,
              modelId: model.id,
            })
          ).rejects.toThrow(
            /Destination of type \"test-plugin-import\" does not support the App .* of type \"test-other-plugin-app\". Supported App types: test-plugin-app./
          );
        });
      });

      test("a destination requires a plugin connection", async () => {
        await expect(
          Destination.create({
            type: "missing-destination",
            name: "test destination",
            appId: app.id,
            modelId: model.id,
          })
        ).rejects.toThrow(
          /Cannot find a \"missing-destination\" connection available within the installed plugins. Current connections installed are:/
        );
      });

      test("__options only includes options for destinations", async () => {
        const destination = await Destination.create({
          id: "myDestinationId",
          type: "test-plugin-export",
          name: "test property",
          appId: app.id,
          modelId: model.id,
        });

        await Option.create({
          ownerId: destination.id,
          ownerType: "destination",
          key: "table",
          value: "users",
          type: "string",
        });

        await Option.create({
          ownerId: destination.id,
          ownerType: "app",
          key: "someOtherProperty",
          value: "someValue",
          type: "string",
        });

        const options = await destination.$get("__options");
        expect(options.length).toBe(1);
        expect(options[0].ownerType).toBe("destination");
        expect(options[0].key).toBe("table");

        await destination.destroy();
      });

      test("options must match the app options (extra options needed by connection)", async () => {
        destination = new Destination({
          name: "incoming destination - too many options",
          type: "test-plugin-export",
          appId: app.id,
          modelId: model.id,
        });
        await destination.save();
        expect(destination.id).toBeTruthy();

        await expect(
          destination.setOptions({
            something: "abc123",
            table: "users_out",
            primaryKey: "userID",
            groupsTable: "groups",
            groupForeignKey: "userId",
            groupColumnName: "groupName",
          })
        ).rejects.toThrow(
          /something is not an option for a test-plugin-export destination/
        );
      });

      test("a valid value for options with multiple choices must be provided ", async () => {
        const destination = await helper.factories.destination();
        expect(destination.id).toBeTruthy();

        await expect(
          destination.setOptions({
            table: "users_out",
            tableWithOptions: "something_else",
          })
        ).rejects.toThrow(
          /"something_else" is not a valid value for test-plugin-export destination option "tableWithOptions"/
        );
      });

      test("required mappings must be included in the mappings", async () => {
        destination = await helper.factories.destination();
        await expect(
          destination.setMapping({
            local_first_name: "firstName",
          })
        ).rejects.toThrow(/primary-id is a required/);
      });

      test("required mappings with a type can only use record properties that match the type", async () => {
        destination = await helper.factories.destination();
        await expect(
          destination.setMapping({
            "primary-id": "email",
          })
        ).rejects.toThrow(/primary-id requires a property of type integer/);
      });

      test("mappings must map to properties", async () => {
        destination = await helper.factories.destination();
        await destination.setMapping({
          "primary-id": "userId",
          local_first_name: "firstName",
        });
        const mapping = await destination.getMapping();
        expect(mapping).toEqual({
          "primary-id": "userId",
          local_first_name: "firstName",
        });
      });

      test("mapping data is cached", async () => {
        destination = await helper.factories.destination();
        const cacheKey = `destination:${destination.id}:mappingOptions`;
        await expect(cache.load(cacheKey)).rejects.toThrow();

        await destination.setMapping({
          "primary-id": "userId",
          local_first_name: "firstName",
        });
        await destination.getMapping();

        const response = await cache.load(cacheKey);
        expect(response.value).not.toBeFalsy();
      });

      test("it throws an error if the mapping does not include the key of a recordPropertyRyle", async () => {
        destination = await helper.factories.destination();
        await expect(
          destination.setMapping({
            "primary-id": "TheUserID",
          })
        ).rejects.toThrow(/cannot find property TheUserID/);
      });

      test("a destination cannot be created in the ready state with missing required options", async () => {
        const destination = Destination.build({
          appId: app.id,
          type: "test-plugin-export",
          syncMode: "sync",
          state: "ready",
          modelId: model.id,
        });

        await expect(destination.save()).rejects.toThrow(/table is required/);
      });

      test("a destination cannot be changed to to the ready state if there are missing required options", async () => {
        destination = await Destination.build({
          appId: app.id,
          name: "missing options",
          type: "test-plugin-export",
        });
        await expect(destination.update({ state: "ready" })).rejects.toThrow();
      });

      test("a destination that is ready cannot move back to draft", async () => {
        destination = await helper.factories.destination();
        await destination.setOptions({ table: "users_out" });
        await destination.update({ state: "ready" });

        await expect(destination.update({ state: "draft" })).rejects.toThrow(
          /cannot transition destination state from ready to draft/
        );
      });

      test("an app can only have one destination per connection with the same options and group tracking", async () => {
        destination = await Destination.create({
          name: "first destination",
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export",
          groupId: "abc123",
          collection: "group",
        });

        await expect(
          Destination.create({
            name: "second destination",
            appId: app.id,
            modelId: model.id,
            type: "test-plugin-export",
            groupId: "abc123",
            collection: "group",
          })
        ).rejects.toThrow(
          /destination "first destination" .* is already using this app with the same options/
        );

        // but it's ok to have the same app track a different group
        const differentGroupDestination = await Destination.create({
          name: "different group destination",
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export", // same
          groupId: "def456", // different
          collection: "group",
        });
        expect(differentGroupDestination.state).toEqual("draft");

        // but it's ok to have one in another type of connection
        const ok = await Destination.create({
          name: "ok destination",
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export-batch", // different
          groupId: "abc123", // same group
          collection: "group",
        });
        expect(ok.state).toEqual("draft");

        await destination.setOptions({ table: "users" });

        const otherDestination = await Destination.create({
          name: "second destination",
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export", // same
          groupId: "abc123", // same
          collection: "group",
        }); // does not throw, as first destination now has new options

        await otherDestination.setOptions({ table: "groups" }); // does not throw

        await expect(
          otherDestination.setOptions({ table: "users" })
        ).rejects.toThrow(
          /destination "first destination" .* is already using this app with the same options/
        );

        await differentGroupDestination.destroy();
        await otherDestination.destroy();

        // and it's ok to have the options here
        await ok.setOptions({ table: "users" });
        await ok.destroy();
      });

      test("an app can only have one destination per connection with the same options and model tracking", async () => {
        destination = await Destination.create({
          name: "first destination",
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export",
          collection: "model",
        });

        await expect(
          Destination.create({
            name: "second destination",
            appId: app.id,
            modelId: model.id,
            type: "test-plugin-export",
            collection: "model",
          })
        ).rejects.toThrow(
          /destination "first destination" .* is already using this app with the same options/
        );

        // but it's ok to have one in another type of connection
        const ok = await Destination.create({
          name: "ok destination",
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export-batch", // different
          collection: "model",
        });
        expect(ok.state).toEqual("draft");

        await destination.setOptions({ table: "users" });

        const otherDestination = await Destination.create({
          name: "second destination",
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export", // same
          collection: "model",
        }); // does not throw, as first destination now has new options

        await otherDestination.setOptions({ table: "groups" }); // does not throw

        await expect(
          otherDestination.setOptions({ table: "users" })
        ).rejects.toThrow(
          /destination "first destination" .* is already using this app with the same options/
        );

        await otherDestination.destroy();

        // and it's ok to have the options here
        await ok.setOptions({ table: "users" });
        await ok.destroy();
      });

      test("a destination cannot be created in the ready state with missing syncMode", async () => {
        const destination = Destination.build({
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export",
          state: "ready",
        });

        await expect(destination.save()).rejects.toThrow(
          /Sync mode is required/
        );
      });

      test("a destination can be ready without syncMode if a default has been defined", async () => {
        const destination = await Destination.create({
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export-batch", //has default mode "additive"
        });

        await destination.setOptions({ table: "users" });
        await destination.update({ state: "ready" });

        const syncMode = await destination.getSyncMode();
        expect(syncMode).toBe("additive");

        await destination.destroy();
      });

      test("destination syncMode must be set on ready", async () => {
        const destination = await Destination.create({
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export",
        });

        await destination.setOptions({ table: "users" });

        await expect(destination.update({ state: "ready" })).rejects.toThrow(
          /Sync mode is required/
        );

        await destination.destroy();
      });

      test("a destination cannot set an unsupported sync mode", async () => {
        const destination = await Destination.create({
          appId: app.id,
          modelId: model.id,
          type: "test-plugin-export",
          syncMode: "RandomSyncMode",
        });

        await destination.setOptions({ table: "users" });

        await expect(destination.update({ state: "ready" })).rejects.toThrow(
          /does not support sync mode/
        );

        await destination.destroy();
      });
    });

    describe("tracking a model", () => {
      let destination: Destination;
      let model: GrouparooModel;

      beforeEach(async () => {
        model = await GrouparooModel.findOne();

        destination = await Destination.create({
          name: "test destination",
          appId: app.id,
          modelId: "mod_profiles",
          syncMode: "sync",
          type: "test-plugin-export",
        });
      });

      afterEach(async () => {
        await destination.destroy();
      });

      test("a model can be tracked", async () => {
        const { newRun } = await destination.updateTracking("model");
        expect(newRun.destinationId).toEqual(destination.id);
        expect(destination.collection).toBe("model");
        expect(destination.modelId).toBe(model.id);
      });

      test("tracking a model will enqueue runs", async () => {
        const { newRun, oldRun } = await destination.updateTracking("model");
        expect(oldRun).toBeFalsy();
        expect(newRun).toBeTruthy();
      });

      test("a destination cannot track an unrelated model", async () => {
        const otherModel = await helper.factories.model({
          id: "otherModel",
          name: "Other Model",
          type: "profile",
        });

        const otherGroup = await helper.factories.group({
          modelId: otherModel.id,
        });

        await expect(
          destination.updateTracking("group", otherGroup.id)
        ).rejects.toThrow(/do not share the same modelId/);

        await otherGroup.destroy();
        await otherModel.destroy();
      });

      test("a model can be unTracked", async () => {
        await destination.updateTracking("model");
        const { oldRun, newRun } = await destination.updateTracking("none");
        expect(oldRun.destinationId).toEqual(destination.id);
        expect(newRun).toBeFalsy();
        expect(destination.collection).toBe("none");
        expect(destination.groupId).toBe(null);
      });

      test("recordPreview - without updates - with model", async () => {
        const record = await helper.factories.record();
        await record.addOrUpdateProperties({
          userId: [1],
          email: ["yoshi@example.com"],
        });
        await destination.updateTracking("model");

        const mapping = {
          "primary-id": "userId",
          email: "email",
        };

        const destinationGroupMemberships: Record<string, string> = {};

        const _record = await destination.recordPreview(
          record,
          mapping,
          destinationGroupMemberships
        );
        expect(_record.properties["primary-id"].values[0]).toBe(1);
        expect(_record.properties["email"].values[0]).toBe("yoshi@example.com");

        await record.destroy();
      });

      test("destination record previews will convert the type of the property to match the destination", async () => {
        const record = await helper.factories.record();
        await record.addOrUpdateProperties({
          userId: [1],
          email: ["yoshi@example.com"],
          ltv: [123],
        });
        await destination.updateTracking("model");

        const mapping = {
          "primary-id": "userId",
          "string-property": "ltv",
        };

        const _record = await destination.recordPreview(record, mapping, {});

        expect(_record.properties["primary-id"].values[0]).toBe(1);
        expect(_record.properties["primary-id"].type).toBe("integer");

        expect(_record.properties["string-property"].values[0]).toBe("123");
        expect(_record.properties["string-property"].type).toBe("string");

        await record.destroy();
      });
    });

    describe("with groups", () => {
      let destination: Destination;
      let group: Group;

      beforeEach(async () => {
        destination = await Destination.create({
          name: "test destination",
          appId: app.id,
          modelId: model.id,
          syncMode: "sync",
          type: "test-plugin-export",
        });

        group = await helper.factories.group();
      });

      afterEach(async () => {
        await group.destroy();
        await destination.destroy();
      });

      test("a group can be tracked", async () => {
        await destination.updateTracking("group", group.id);
        const _group = await destination.$get("group");
        expect(_group.id).toBe(group.id);
      });

      test("a group cannot be tracked in any other collection", async () => {
        await expect(
          destination.updateTracking("none", group.id)
        ).rejects.toThrow(/cannot track/);
        await expect(
          destination.updateTracking("model", group.id)
        ).rejects.toThrow(/cannot track/);
      });

      test("tracking a group will enqueue runs", async () => {
        const { oldRun, newRun } = await destination.updateTracking(
          "group",
          group.id
        );
        expect(oldRun).toBeFalsy();
        expect(newRun).toBeTruthy();
      });

      test("a destination can only track a single group", async () => {
        const newGroup = await helper.factories.group();

        await destination.updateTracking("group", group.id);
        await destination.updateTracking("group", newGroup.id);
        const _group = await destination.$get("group");
        expect(_group.id).toBe(newGroup.id);
        await newGroup.destroy();
      });

      test("a destination cannot track a deleted group", async () => {
        await group.update({ state: "deleted" });

        await expect(
          destination.updateTracking("group", group.id)
        ).rejects.toThrow(/cannot track deleted Group/);
        const _group = await destination.$get("group");
        expect(_group).toBe(null);
      });

      test("a group can be unTracked", async () => {
        await destination.updateTracking("group", group.id);
        let _group = await destination.$get("group");
        expect(_group.id).toBe(group.id);

        await destination.updateTracking("none");
        _group = await destination.$get("group");
        expect(_group).toBe(null);
      });

      test("recordPreview - without updates - with group", async () => {
        const record = await helper.factories.record();
        await record.addOrUpdateProperties({
          userId: [1],
          email: ["yoshi@example.com"],
        });
        await GroupMember.create({ recordId: record.id, groupId: group.id });
        await destination.updateTracking("group", group.id);

        const mapping = {
          "primary-id": "userId",
          email: "email",
        };

        const destinationGroupMemberships: Record<string, string> = {};
        destinationGroupMemberships[group.id] = "another-group-tag";

        const _record = await destination.recordPreview(
          record,
          mapping,
          destinationGroupMemberships
        );
        expect(_record.properties["primary-id"].values[0]).toBe(1);
        expect(_record.properties["email"].values[0]).toBe("yoshi@example.com");
        expect(_record.groupNames).toEqual(["another-group-tag"]);

        await record.destroy();
      });

      test("destination record previews will convert the type of the property to match the destination", async () => {
        const record = await helper.factories.record();
        await record.addOrUpdateProperties({
          userId: [1],
          email: ["yoshi@example.com"],
          ltv: [123],
        });
        await GroupMember.create({ recordId: record.id, groupId: group.id });
        await destination.updateTracking("group", group.id);

        const mapping = {
          "primary-id": "userId",
          "string-property": "ltv",
        };

        const _record = await destination.recordPreview(record, mapping, {});

        expect(_record.properties["primary-id"].values[0]).toBe(1);
        expect(_record.properties["primary-id"].type).toBe("integer");

        expect(_record.properties["string-property"].values[0]).toBe("123");
        expect(_record.properties["string-property"].type).toBe("string");

        await record.destroy();
      });

      describe("relevantFor", () => {
        it("determined relevant destinations for a record", async () => {
          const otherGroupDestination = await helper.factories.destination();
          const modelDestination = await helper.factories.destination();
          const otherGroup = await helper.factories.group();
          const irrelevantDestination = await helper.factories.destination();
          await irrelevantDestination.updateTracking("group", otherGroup.id);

          const record = await helper.factories.record();
          await GroupMember.create({ recordId: record.id, groupId: group.id });

          // before the destinations are ready
          await destination.updateTracking("group", group.id);
          let destinations = await DestinationOps.relevantFor(
            record,
            await record.$get("groups")
          );
          expect(destinations.length).toBe(0);

          // after the destinations are ready
          await destination.setOptions({ table: "users" });
          await destination.update({ state: "ready" });
          await otherGroupDestination.setOptions({ table: "users" });
          await otherGroupDestination.updateTracking("group", group.id);
          await modelDestination.updateTracking("model");

          destinations = await DestinationOps.relevantFor(
            record,
            await record.$get("groups")
          );
          expect(destinations.length).toBe(3);
          expect(destinations.map((d) => d.id).sort()).toEqual(
            [
              destination.id,
              otherGroupDestination.id,
              modelDestination.id,
            ].sort()
          );
          expect(destinations.map((d) => d.id).sort()).not.toContain(
            irrelevantDestination.id
          );

          await destination.updateTracking("none");
          await GroupMember.destroy({ where: { recordId: record.id } });

          await otherGroupDestination.updateTracking("none");
          await otherGroupDestination.destroy();
          await modelDestination.updateTracking("none");
          await modelDestination.destroy();
          await irrelevantDestination.updateTracking("none");
          await irrelevantDestination.destroy();
          await otherGroup.destroy();
        });

        test("when the group being tracked is removed, the previous group should be exported one last time", async () => {
          const { newRun: runA } = await destination.updateTracking(
            "group",
            group.id
          );

          const { oldRun: runB } = await destination.updateTracking("none");
          await runA.reload();
          await runB.reload();

          expect(runA.creatorId).toEqual(runB.creatorId);
          expect(runA.state).toBe("stopped");
          expect(runB.state).toBe("running");
          expect(runA.destinationId).toBe(destination.id);
          expect(runB.destinationId).toBe(destination.id);
        });

        test("when the model being tracked is removed, the previous records should be exported one last time", async () => {
          const { newRun: runA } = await destination.updateTracking("model");
          const { oldRun: runB } = await destination.updateTracking("none");
          await runA.reload();
          await runB.reload();

          expect(runA.creatorId).toEqual(runB.creatorId);
          expect(runA.state).toBe("stopped");
          expect(runB.state).toBe("running");
          expect(runA.destinationId).toBe(destination.id);
          expect(runB.destinationId).toBe(destination.id);
        });

        test("when the group being tracked is changed, the previous group should be exported one last time", async () => {
          const otherGroup = await helper.factories.group();
          await otherGroup.update({ state: "ready" });
          const { newRun: runA } = await destination.updateTracking(
            "group",
            group.id
          );
          const { newRun: runB } = await destination.updateTracking(
            "group",
            otherGroup.id
          );

          expect(runA.creatorId).toEqual(group.id);
          expect(runB.creatorId).toEqual(otherGroup.id);
          expect(runA.state).toBe("running");
          expect(runB.state).toBe("running");
          expect(runB.destinationId).toBe(destination.id);

          await otherGroup.destroy();
        });
      });
    });

    describe("parameterizedOptions", () => {
      it("replaces mustache template strings with variables", async () => {
        destination = new Destination({
          name: "outgoing pg destination",
          type: "test-plugin-export",
          appId: app.id,
          modelId: model.id,
        });
        await destination.save();

        await destination.setOptions({
          table: "users",
          where: "thing-{{{ now.sql }}}",
        });

        const parameterizedOptions = await destination.parameterizedOptions();
        const now = new Date();
        expect(parameterizedOptions.where).toMatch(
          `thing-${now.getFullYear()}`
        );
      });
    });
  });
});
