@grouparoo/core
Version:
The Grouparoo Core
754 lines (653 loc) • 26.8 kB
text/typescript
import { helper } from "@grouparoo/spec-helper";
import { specHelper, config } from "actionhero";
import { GrouparooRecord, Group, Run, Import, GroupMember } from "../../../src";
import { SharedGroupTests } from "../../utils/prepareSharedGroupTest";
import { GroupOps } from "../../../src/modules/ops/group";
import { GroupRuleWithKey } from "../../../src/models/Group";
describe("models/group", () => {
helper.grouparooTestServer({ truncate: true, enableTestPlugin: true });
describe("groups calculations", () => {
let run: Run;
let group: Group;
let mario: GrouparooRecord;
let luigi: GrouparooRecord;
let peach: GrouparooRecord;
let toad: GrouparooRecord;
beforeAll(async () => {
const response = await SharedGroupTests.beforeAll();
mario = response.mario;
luigi = response.luigi;
peach = response.peach;
toad = response.toad;
});
beforeEach(async () => {
const response = await SharedGroupTests.beforeEach();
group = response.group;
run = await helper.factories.run();
});
afterEach(async () => {
await SharedGroupTests.afterEach();
await run.destroy();
process.env.GROUPAROO_RUN_MODE = undefined;
});
test("an empty group can be created", async () => {
const members = await group.$get("groupMembers");
expect(members.length).toBe(0);
expect(group.state).toBe("draft");
});
test("setting rules outside of the dictionary will fail", async () => {
await expect(
group.setRules([
//@ts-ignore We are checking something explicitly not in the type
{ key: "firstName", match: "nobody", operation: { op: "wacky" } },
])
).rejects.toThrow(
/invalid group rule operation "wacky" for property of type string/
);
});
if (config.sequelize.dialect !== "sqlite") {
test("setting rules that fail a SQL comparison", async () => {
await expect(
group.setRules([
{ key: "ltv", match: "fish", operation: { op: "gt" } },
])
).rejects.toThrow(/fish/); // the error message is dependant on the database, but should contain the column name
});
}
test("changing group rules changes the state to initializing and enquires a run, and then back to ready when complete", async () => {
await group.setRules([
{ key: "firstName", match: "nobody", operation: { op: "eq" } },
]);
expect(group.state).toBe("updating");
const run = await Run.findOne({
where: { state: "running", creatorId: group.id },
});
await specHelper.runTask("group:run", { runId: run.id }); // first run to check additions
await specHelper.runTask("group:run", { runId: run.id }); // second run to check subtractions
await specHelper.runTask("group:run", { runId: run.id }); // third run to check old group members
await specHelper.runTask("group:run", { runId: run.id }); // final run to mark complete
await run.reload();
expect(run.state).toBe("complete");
expect(run.method).toBe("complete");
await group.reload();
expect(group.state).toBe("ready");
});
test("groups with at least one rule are set to ready in config mode", async () => {
process.env.GROUPAROO_RUN_MODE = "cli:config";
expect(group.state).toBe("draft");
await group.setRules([
{ key: "firstName", match: "nobody", operation: { op: "eq" } },
]);
expect(group.state).toBe("ready");
});
test("changing the rules will stop previously running runs", async () => {
await group.setRules([
{ key: "firstName", match: "nobody", operation: { op: "eq" } },
]);
const firstRun = await Run.findOne({
where: { creatorId: group.id },
});
expect(firstRun.state).toBe("running");
await group.setRules([
{ key: "lastName", match: "nobody", operation: { op: "eq" } },
]);
await firstRun.reload();
expect(firstRun.state).toBe("stopped");
});
test("we can determine if group rules have been changed", async () => {
await group.setRules([
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
const rules = await group.getRules();
expect(GroupOps.rulesAreEqual(rules, [])).toBe(false);
expect(
GroupOps.rulesAreEqual(rules, [
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
])
).toBe(true);
expect(
GroupOps.rulesAreEqual(rules, [
{ key: "firstName", match: "Luigi", operation: { op: "eq" } },
])
).toBe(false);
expect(
GroupOps.rulesAreEqual(rules, [
{ key: "LastName", match: "Mario", operation: { op: "eq" } },
])
).toBe(false);
expect(
GroupOps.rulesAreEqual(rules, [
{ key: "firstName", match: "Mario", operation: { op: "ne" } },
])
).toBe(false);
});
describe.each(["any", "all"] as const)(
'with match type "%s"',
(matchType) => {
beforeEach(async () => {
await group.update({ matchType });
});
test("group#runAddGroupMembers will create an import for new members, and touch the updatedAt for existing members", async () => {
await group.setRules([
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
const response = await group.runAddGroupMembers(run);
expect(response).toEqual(
expect.objectContaining({
groupMembersCount: 1,
nextOffset: 0,
})
);
// first time
const imports = await Import.findAll({
where: { creatorId: run.id },
});
expect(imports.length).toBe(1);
expect(imports[0].recordId).toBe(mario.id);
// create the groupMember
await mario.updateGroupMembership();
const groupMember = await GroupMember.findOne({
where: { groupId: group.id, recordId: mario.id },
});
const firstTime = groupMember.updatedAt.getTime();
await helper.sleep(1001);
// second time
await mario.reload();
await mario.update({ state: "ready" }); // the import would have made the state 'pending'
await group.runAddGroupMembers(run);
await groupMember.reload();
expect(groupMember.updatedAt.getTime()).toBeGreaterThan(
groupMember.createdAt.getTime()
);
expect(groupMember.updatedAt.getTime()).toBeGreaterThan(firstTime);
});
test("group#runAddGroupMembers will include ready records", async () => {
await group.setRules([
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
await group.runAddGroupMembers(run);
const imports = await Import.findAll({
where: { creatorId: run.id },
});
expect(imports.length).toBe(1);
expect(imports[0].recordId).toBe(mario.id);
});
test("group#runAddGroupMembers will ignore not-ready records", async () => {
await group.setRules([
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
await mario.update({ state: "pending" });
await group.runAddGroupMembers(run);
const imports = await Import.findAll({
where: { creatorId: run.id },
});
expect(imports.length).toBe(0);
});
test("group#runRemoveGroupMembers will create imports for records which should no longer be part of the group and mark removedAt on the group member", async () => {
await group.setRules([
{ key: "lastName", match: "Mario", operation: { op: "eq" } }, // mario and luigi
]);
const firstAddResponse = await group.runAddGroupMembers(run);
const firstRemoveResponse = await group.runRemoveGroupMembers(run);
expect(firstAddResponse).toEqual(
expect.objectContaining({
groupMembersCount: 2,
nextOffset: 0,
})
);
expect(firstRemoveResponse).toEqual(0);
// create the groupMembers
await mario.updateGroupMembership();
await luigi.updateGroupMembership();
const firstGroupMembers = await GroupMember.findAll({
where: { groupId: group.id },
});
expect(firstGroupMembers.length).toBe(2);
await mario.reload();
await luigi.reload();
await mario.update({ state: "ready" });
await luigi.update({ state: "ready" });
// next run
await group.setRules([
{ key: "firstName", match: "Mario", operation: { op: "eq" } }, // just mario
]);
const nextRun = await helper.factories.run();
const secondAddResponse = await group.runAddGroupMembers(nextRun);
const secondRemoveResponse = await group.runRemoveGroupMembers(
nextRun
);
expect(secondAddResponse).toEqual(
expect.objectContaining({
groupMembersCount: 1,
nextOffset: 0,
})
);
expect(secondRemoveResponse).toEqual(1);
const imports = await Import.findAll({
where: { creatorId: nextRun.id },
});
expect(imports.length).toBe(1);
expect(imports[0].recordId).toBe(luigi.id);
const luigiGroupMember = await GroupMember.findOne({
where: { recordId: luigi.id, groupId: group.id },
});
expect(luigiGroupMember.removedAt).toBeTruthy();
await mario.updateGroupMembership();
await luigi.updateGroupMembership();
const secondGroupMembers = await GroupMember.findAll({
where: { groupId: group.id },
});
expect(secondGroupMembers.length).toBe(1);
await mario.reload();
await luigi.reload();
await nextRun.destroy();
});
describe("group rules", () => {
test("it returns 0 members when no rules exist on the group", async () => {
await group.setRules([]);
expect(await group.countPotentialMembers()).toBe(0);
});
});
}
);
test("runAddGroupMembers updates calculatedAt", async () => {
expect(group.calculatedAt).toBeFalsy();
await group.setRules([
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
await group.runAddGroupMembers(run);
expect(group.calculatedAt).toBeTruthy();
});
test("runRemoveGroupMembers updates calculatedAt", async () => {
expect(group.calculatedAt).toBeFalsy();
await group.setRules([
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
await group.runRemoveGroupMembers(run);
expect(group.calculatedAt).toBeTruthy();
});
describe("group#nextCalculatedAt", () => {
test("returns for a group with relative rules ", async () => {
await group.setRules([
{
key: "lastLoginAt",
operation: { op: "gt" },
relativeMatchDirection: "subtract",
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
]);
expect(group.calculatedAt).toBeFalsy();
expect((await group.nextCalculatedAt()).getTime() / 1000).toBeCloseTo(
new Date().getTime() / 1000
);
await group.runAddGroupMembers(run);
expect((await group.nextCalculatedAt()).getTime()).toBeGreaterThan(
new Date().getTime()
);
});
test("does not return for groups without relative rules", async () => {
await group.setRules([
{
key: "ltv",
operation: { op: "gt" },
match: "3",
},
]);
expect(await group.nextCalculatedAt()).toBeNull();
});
});
test("runUpdateMembers with a destination will create imports for all members and include a destinationId in _meta", async () => {
await group.setRules([
{ key: "lastName", operation: { op: "eq" }, match: "Mario" },
]);
await GroupOps.updateRecords([mario.id], "group", group.id); // make an import
await GroupOps.updateRecords([luigi.id], "group", group.id); // make an import
await GroupMember.create({ recordId: mario.id, groupId: group.id });
await GroupMember.create({ recordId: luigi.id, groupId: group.id });
await mario.reload();
await luigi.reload();
await peach.reload();
await toad.reload();
await mario.update({ state: "ready" });
await luigi.update({ state: "ready" });
await toad.update({ state: "ready" });
await peach.update({ state: "ready" });
let imports = await Import.findAll();
expect(imports.length).toBe(2);
await Import.truncate();
const run = await group.run("abc123");
await specHelper.runTask("group:run", { runId: run.id });
imports = await Import.findAll();
expect(imports.map((i) => i.recordId).sort()).toEqual(
[mario, luigi].map((p) => p.id).sort()
);
const data = { _meta: { destinationId: "abc123" } };
expect(imports[0].data).toEqual(data);
expect(imports[1].data).toEqual(data);
});
test("group rules must have a related recordProperty Ryle", async () => {
await expect(
group.setRules([{ key: "a", match: "cool", operation: { op: "eq" } }])
).rejects.toThrow(/cannot find property a/);
});
test("recalculating group membership will reuse existing groupMembers", async () => {
await group.update({ matchType: "all" });
await group.setRules([
{ key: "lastName", match: "Mario", operation: { op: "eq" } },
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
await group.runAddGroupMembers(run);
await mario.updateGroupMembership();
const members = await group.$get("groupMembers");
expect(members.length).toBe(1);
const groupMemberId = members[0].id;
const secondRun = await helper.factories.run();
await group.runAddGroupMembers(secondRun);
await mario.updateGroupMembership();
const membersAgain = await group.$get("groupMembers");
expect(membersAgain[0].id).toBe(groupMemberId);
await secondRun.destroy();
});
test("calculating a group with more than 10 rules produces an error", async () => {
group.matchType = "all";
await expect(
group.setRules([
{ key: "userId", match: "Mario", operation: { op: "eq" } },
{ key: "lastName", match: "Mario", operation: { op: "eq" } },
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
{ key: "email", match: "Mario", operation: { op: "eq" } },
{ key: "isVIP", match: "Mario", operation: { op: "eq" } },
{ key: "ltv", match: "0", operation: { op: "eq" } },
{ key: "ltv", match: "1", operation: { op: "eq" } },
{ key: "ltv", match: "2", operation: { op: "eq" } },
{ key: "ltv", match: "3", operation: { op: "eq" } },
{ key: "ltv", match: "4", operation: { op: "eq" } },
{ key: "ltv", match: "5", operation: { op: "eq" } },
])
).rejects.toThrow(/too many group rules/);
});
describe("convenientRules", () => {
describe("fromConvenientRules", () => {
test("exists", async () => {
const convenientRules: GroupRuleWithKey[] = [
{ key: "email", operation: { op: "exists" } },
];
const rules = [
{ key: "email", match: "null", operation: { op: "ne" } },
];
expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
});
test("notExists", async () => {
const convenientRules: GroupRuleWithKey[] = [
{ key: "email", operation: { op: "notExists" } },
];
const rules = [
{ key: "email", match: "null", operation: { op: "eq" } },
];
expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
});
test("relative_gt", async () => {
const convenientRules: GroupRuleWithKey[] = [
{
key: "lastLoginAt",
operation: { op: "relative_gt" },
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
const rules = [
{
key: "lastLoginAt",
operation: { op: "gt" },
relativeMatchDirection: "subtract",
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
});
test("relative_lt", async () => {
const convenientRules: GroupRuleWithKey[] = [
{
key: "lastLoginAt",
operation: { op: "relative_lt" },
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
const rules = [
{
key: "lastLoginAt",
operation: { op: "lt" },
relativeMatchDirection: "add",
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
});
});
describe("toConvenientRules", () => {
test("exists", async () => {
const convenientRules = [
{ type: "email", key: "email", operation: { op: "exists" } },
];
const rules: GroupRuleWithKey[] = [
{
type: "email",
key: "email",
match: "null",
operation: { op: "ne" },
},
];
expect(group.toConvenientRules(rules)).toEqual(convenientRules);
});
test("notExists", async () => {
const convenientRules = [
{ type: "email", key: "email", operation: { op: "notExists" } },
];
const rules: GroupRuleWithKey[] = [
{
type: "email",
key: "email",
match: "null",
operation: { op: "eq" },
},
];
expect(group.toConvenientRules(rules)).toEqual(convenientRules);
});
test("relative_gt", async () => {
const convenientRules = [
{
type: "date",
key: "lastLoginAt",
operation: { op: "relative_gt", description: "is in the past" },
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
const rules: GroupRuleWithKey[] = [
{
type: "date",
key: "lastLoginAt",
operation: { op: "gt" },
relativeMatchDirection: "subtract",
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
expect(group.toConvenientRules(rules)).toEqual(convenientRules);
});
test("relative_lt", async () => {
const convenientRules = [
{
type: "date",
key: "lastLoginAt",
operation: { op: "relative_lt", description: "is in the future" },
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
const rules: GroupRuleWithKey[] = [
{
type: "date",
key: "lastLoginAt",
operation: { op: "lt" },
relativeMatchDirection: "add",
relativeMatchNumber: 1,
relativeMatchUnit: "days",
},
];
expect(group.toConvenientRules(rules)).toEqual(convenientRules);
});
});
test("convenientRules work with rulesAreEqual", async () => {
await group.setRules(
group.fromConvenientRules([
{ key: "firstName", operation: { op: "exists" } },
])
);
const rules = await group.getRules();
expect(
GroupOps.rulesAreEqual(rules, [
{
key: "firstName",
operation: { op: "ne" },
match: "null",
},
])
).toBe(true);
});
});
describe("#updateProfilesMembership", () => {
describe("group", () => {
test("groups with no rules will not have members added", async () => {
await group.setRules([]);
const belongs = await group.updateRecordsMembership([mario]);
expect(belongs).toEqual({ [mario.id]: false });
});
test("it will add a record not yet in the group", async () => {
let members = await group.$get("groupMembers");
expect(members.length).toBe(0);
await group.update({ matchType: "all" });
await group.setRules([
{ key: "lastName", match: "Mario", operation: { op: "eq" } },
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
const belongs = await group.updateRecordsMembership([mario]);
expect(belongs).toEqual({ [mario.id]: true });
members = await group.$get("groupMembers");
expect(members.length).toBe(1);
expect(members[0].recordId).toBe(mario.id);
});
test("it works with multiple records", async () => {
await group.update({ matchType: "all" });
await group.setRules([
{ key: "lastName", match: "Mario", operation: { op: "eq" } },
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
const belongs = await group.updateRecordsMembership([mario, luigi]);
expect(belongs).toEqual({ [mario.id]: true, [luigi.id]: false });
});
test("it will leave a group member in the group", async () => {
await group.update({ matchType: "all" });
await group.setRules([
{ key: "lastName", match: "Mario", operation: { op: "eq" } },
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
const belongsA = await group.updateRecordsMembership([mario]);
expect(belongsA).toEqual({ [mario.id]: true });
const belongsB = await group.updateRecordsMembership([mario]);
expect(belongsB).toEqual({ [mario.id]: true });
const members = await group.$get("groupMembers");
expect(members.length).toBe(1);
expect(members[0].recordId).toBe(mario.id);
});
test("it will remove a record from the group", async () => {
await group.update({ matchType: "all" });
await group.setRules([
{ key: "lastName", match: "Mario", operation: { op: "eq" } },
{ key: "firstName", match: "Mario", operation: { op: "eq" } },
]);
let belongs = await group.updateRecordsMembership([mario]);
expect(belongs).toEqual({ [mario.id]: true });
let members = await group.$get("groupMembers");
expect(members.length).toBe(1);
expect(members[0].recordId).toBe(mario.id);
await group.setRules([
{ key: "lastName", match: "Lakitu", operation: { op: "eq" } },
]);
belongs = await group.updateRecordsMembership([mario]);
expect(belongs).toEqual({ [mario.id]: false });
members = await group.$get("groupMembers");
expect(members.length).toBe(0);
});
});
});
describe("#countPotentialMembers", () => {
test("it will count the records which would become members at the next run by default", async () => {
await group.update({ matchType: "any" });
await group.setRules([
{
key: "lastLoginAt",
match: new Date(0).getTime(),
operation: { op: "gt" },
},
{ key: "lastName", match: "Mario", operation: { op: "like" } },
]);
const count = await group.countPotentialMembers();
expect(count).toBe(3);
});
test("it can return a count of records which would match an arbitrary rule set", async () => {
await group.update({ matchType: "all" });
await group.setRules([]);
const rules: GroupRuleWithKey[] = [
{
key: "lastLoginAt",
match: new Date(100000).getTime(),
operation: { op: "gte" },
},
{ key: "lastName", match: "Mario", operation: { op: "like" } },
];
const count = await group.countPotentialMembers(rules);
expect(count).toBe(1);
});
});
describe("#countComponentMembersFromRules", () => {
test("we can count the membership subtotals for each part of the group rule", async () => {
await group.update({ matchType: "all" });
await group.setRules([]);
const rules: GroupRuleWithKey[] = [
{
key: "firstName",
match: "%",
operation: { op: "like" },
},
{
key: "lastLoginAt",
match: new Date(100000).getTime(),
operation: { op: "gte" },
},
{
key: "lastName",
match: "Mario",
operation: { op: "like" },
},
];
const count = await group.countPotentialMembers(rules);
expect(count).toBe(1);
const { componentCounts, funnelCounts } =
await group.countComponentMembersFromRules(rules);
// if we just had the rule `firstName: { match: "%", op: "iLike" },` there would be 4 group members
// if we just had the rule `lastLoginAt: { match: new Date(100000).getTime(), op: "gte" }` there would be 2 group members
// if we just had the rule `lastName: { match: "mario", op: "iLike" }` there would be 2 group members
expect(componentCounts).toEqual([4, 2, 2]);
// checking just the first rule (`firstName`) there would have been 2 group members
// checking the first & second rule (`firstName` + `lastLoginAt`) there would have been 2 group members
// checking all 3 rules (`firstName` + `lastLoginAt` + `lastLoginAt`), there would have been 1 group member
expect(funnelCounts).toEqual([4, 2, 1]);
});
});
});
});