@grouparoo/core
Version:
The Grouparoo Core
1,302 lines (1,135 loc) • 42.9 kB
text/typescript
import { helper } from "@grouparoo/spec-helper";
import { api } from "actionhero";
import {
plugin,
GrouparooRecord,
RecordProperty,
Property,
Group,
App,
Source,
GrouparooModel,
} from "../../../src";
import { RecordOps } from "../../../src/modules/ops/record";
function simpleRecordValues(complexProfileValues: Record<string, any>) {
const keys = Object.keys(complexProfileValues);
const simpleRecordProperties: Record<string, any[]> = {};
keys.forEach((key) => {
simpleRecordProperties[key] = complexProfileValues[key].values;
});
return simpleRecordProperties;
}
describe("models/record", () => {
let model: GrouparooModel;
helper.grouparooTestServer({ truncate: true, enableTestPlugin: true });
beforeAll(async () => {
model = await helper.factories.model();
});
test("a record can be created", async () => {
const record = new GrouparooRecord({ modelId: model.id });
await record.save();
expect(record.id.length).toBe(40);
expect(record.createdAt).toBeTruthy();
expect(record.updatedAt).toBeTruthy();
});
test("records require a valid modelId", async () => {
expect(GrouparooRecord.create()).rejects.toThrow(/modelId cannot be null/);
expect(GrouparooRecord.create({ modelId: "foo" })).rejects.toThrow(
/cannot find model with id "foo"/
);
});
test("records cannot change models", async () => {
const record = await GrouparooRecord.create({ modelId: model.id });
await expect(record.update({ modelId: "foo" })).rejects.toThrow(
/cannot change models/
);
await record.destroy();
});
describe("findOrCreateByUniqueRecordProperties", () => {
let source: Source;
let toad: GrouparooRecord;
let emailProperty: Property;
let userIdProperty: Property;
let colorProperty: Property;
let houseProperty: Property;
beforeAll(async () => {
await GrouparooRecord.truncate();
source = await helper.factories.source();
await source.setOptions({ table: "users" });
await source.bootstrapUniqueProperty({
key: "userId",
type: "integer",
mappedColumn: "id",
});
await source.setMapping({ id: "userId" });
await source.update({ state: "ready" });
userIdProperty = await Property.findOne({
where: { key: "userId" },
});
emailProperty = await Property.create({
sourceId: source.id,
key: "email",
type: "email",
unique: true,
});
await emailProperty.setOptions({ column: "email" });
await emailProperty.update({ state: "ready" });
colorProperty = await Property.create({
sourceId: source.id,
key: "color",
type: "string",
unique: false,
});
await colorProperty.setOptions({ column: "color" });
await colorProperty.update({ state: "ready" });
houseProperty = await Property.create({
sourceId: source.id,
key: "house",
type: "string",
unique: false,
});
await houseProperty.setOptions({ column: "house" });
await houseProperty.update({ state: "ready" });
const record = await GrouparooRecord.create({ modelId: model.id });
await record.addOrUpdateProperties({
email: ["toad@example.com"],
color: ["orange"],
});
toad = record;
});
afterAll(async () => {
await source.setMapping({});
await toad.destroy();
await userIdProperty.destroy();
await emailProperty.destroy();
await colorProperty.destroy();
await houseProperty.destroy();
await source.destroy();
});
test("it can find the record via email", async () => {
const [{ record, isNew, referenceId, error }] =
await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
email: ["toad@example.com"],
color: ["orange"],
},
],
["_"]
);
expect(referenceId).toBe("_");
expect(error).toBeUndefined();
expect(isNew).toBe(false);
expect(record.id).toBe(toad.id);
});
test("it cannot find the record by color and will create a new one", async () => {
const [{ record, isNew, referenceId, error }] =
await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
email: ["luigi@example.com"],
color: ["green"],
},
],
["_"],
source
);
expect(referenceId).toBe("_");
expect(error).toBeUndefined();
expect(isNew).toBe(true);
expect(record.id).not.toBe(toad.id);
});
test("it will return an error if no unique record properties are included", async () => {
const [response] = await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
color: ["orange"],
},
],
["_"],
source
);
expect(response.error.message).toMatch(
'there are no unique record properties provided in {"color":["orange"]} (_)'
);
});
test("it will lock when creating new records so duplicate records are not created", async () => {
const [responseA] = await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
email: ["bowser@example.com"],
color: ["green"],
},
],
["_"],
source
);
const [responseB] = await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
email: ["bowser@example.com"],
house: ["castle"],
},
],
["_"],
source
);
expect(responseA.record.id).toEqual(responseB.record.id);
expect(responseA.isNew).toBe(true);
expect(responseB.isNew).toBe(false);
});
test("it will merge overlapping unique properties and not store non-unique properties", async () => {
const [responseA] = await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
email: ["koopa@example.com"],
userId: [99],
},
],
["_"],
source
);
const [responseB] = await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
userId: [99],
house: ["castle"],
},
],
["_"],
source
);
expect(responseA.record.id).toEqual(responseB.record.id);
expect(responseA.isNew).toBe(true);
expect(responseB.isNew).toBe(false);
const record = responseB.record;
const properties = await record.getProperties();
expect(properties.email.values).toEqual(["koopa@example.com"]);
expect(properties.userId.values).toEqual([99]);
expect(properties.house.values).toEqual([null]);
expect(properties.house.values).toEqual([null]);
});
test("cannot create a new record without a source or override", async () => {
const responseA = await RecordOps.findOrCreateByUniqueRecordProperties(
[
{
email: ["yoshi@example.com"],
},
],
["_"]
);
expect(responseA[0].error.message).toMatch(
'could not create a new record because no record property in {"email":["yoshi@example.com"]} is unique and owned by the source'
);
const responseB = await RecordOps.findOrCreateByUniqueRecordProperties(
[{ email: ["yoshi@example.com"] }],
["_"],
false
);
expect(responseB[0].error.message).toMatch(
'could not create a new record because no record property in {"email":["yoshi@example.com"]} is unique and owned by the source'
);
});
test("properties will include the value, type, unique, and timestamps", async () => {
const properties = await toad.getProperties();
expect(properties.email.type).toBe("email");
expect(properties.email.unique).toBe(true);
expect(properties.email.values[0]).toBe("toad@example.com");
expect(properties.email.createdAt).toBeTruthy();
expect(properties.email.updatedAt).toBeTruthy();
});
});
describe("record property helpers", () => {
let record: GrouparooRecord;
beforeAll(async () => {
await GrouparooRecord.truncate();
});
test("it cannot add a record property that is not defined", async () => {
record = new GrouparooRecord({ modelId: model.id });
await record.save();
await expect(
record.addOrUpdateProperties({ email: ["luigi@example.com"] })
).rejects.toThrow("cannot find a property for id or key `email`");
await record.destroy();
});
describe("with properties", () => {
let source: Source;
beforeAll(async () => {
source = await helper.factories.source();
await source.setOptions({ table: "users" });
await source.bootstrapUniqueProperty({
key: "userId",
type: "integer",
mappedColumn: "id",
});
await source.setMapping({ id: "userId" });
await source.update({ state: "ready" });
const emailProperty = await Property.create({
sourceId: source.id,
key: "email",
type: "string",
});
await emailProperty.setOptions({ column: "email" });
await emailProperty.update({ state: "ready" });
const firstNameProperty = await Property.create({
sourceId: source.id,
key: "firstName",
type: "string",
});
await firstNameProperty.setOptions({ column: "firstName" });
await firstNameProperty.update({ state: "ready" });
const lastNameProperty = await Property.create({
sourceId: source.id,
key: "lastName",
type: "string",
});
await lastNameProperty.setOptions({ column: "lastName" });
await lastNameProperty.update({ state: "ready" });
const colorProperty = await Property.create({
sourceId: source.id,
key: "color",
type: "string",
});
await colorProperty.setOptions({ column: "color" });
await colorProperty.update({ state: "ready" });
});
beforeAll(async () => {
record = new GrouparooRecord({ modelId: model.id });
await record.save();
});
afterAll(async () => {
await source.setMapping({});
await Property.truncate();
await source.destroy();
});
test("creating a record creates null record properties", async () => {
const newProfile = await GrouparooRecord.create({
modelId: model.id,
});
const properties = await newProfile.getProperties();
expect(Object.keys(properties).length).toBe(5);
for (const k in properties) {
expect(properties[k].values).toEqual([null]);
expect(properties[k].state).toEqual("pending");
}
});
test("a record can be marked as pending and it's properties will be marked as pending as well", async () => {
const newProfile = await GrouparooRecord.create({
modelId: model.id,
});
await RecordProperty.update(
{ state: "ready" },
{ where: { recordId: newProfile.id } }
);
await newProfile.update({ state: "ready" });
await newProfile.markPending();
await newProfile.reload();
expect(newProfile.state).toBe("pending");
const properties = await newProfile.getProperties();
for (const k in properties) {
expect(properties[k].state).toEqual("pending");
expect(properties[k].startedAt).toBeNull();
}
});
test("it can add a new record property when the schema is prepared", async () => {
await record.addOrUpdateProperties({ email: ["luigi@example.com"] });
const properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
email: ["luigi@example.com"],
firstName: [null],
lastName: [null],
userId: [null],
color: [null],
});
});
test("adding values to record properties moves them to the ready state", async () => {
await RecordProperty.update(
{ state: "pending" },
{ where: { recordId: record.id } }
);
await record.addOrUpdateProperties({ email: ["luigi@example.com"] });
const properties = await record.getProperties();
expect(properties.email.state).toBe("ready");
expect(properties.firstName.state).toBe("pending");
});
test("record cannot transition to ready state until all properties are ready", async () => {
await RecordProperty.update(
{ state: "pending" },
{ where: { recordId: record.id } }
);
await expect(record.update({ state: "ready" })).rejects.toThrow(
/cannot transition record .* to ready state as not all properties are ready/
);
});
test("record can transition to ready state if all properties are ready", async () => {
await RecordProperty.update(
{ state: "ready" },
{ where: { recordId: record.id } }
);
await record.update({ state: "ready" }); // does not throw
});
describe("record property timestamps (non-array)", () => {
test("changing a value sets valueChangedAt and confirmedAt", async () => {
const start = new Date().getTime();
await helper.sleep(1000);
await record.addOrUpdateProperties({
email: ["new-email@example.com"],
});
const properties = await record.getProperties();
expect(properties.email.valueChangedAt.getTime()).toBeGreaterThan(
start
);
expect(properties.email.confirmedAt.getTime()).toBeGreaterThan(start);
});
test("updating with the same value only sets confirmedAt", async () => {
const start = new Date().getTime();
await helper.sleep(1000);
await record.addOrUpdateProperties({
email: ["new-email@example.com"],
});
const properties = await record.getProperties();
expect(properties.email.valueChangedAt.getTime()).toBeLessThan(start);
expect(properties.email.confirmedAt.getTime()).toBeGreaterThan(start);
});
test("changing state sets stateChangedAt", async () => {
await RecordProperty.update(
{ state: "pending" },
{ where: { recordId: record.id } }
);
const start = new Date().getTime();
await helper.sleep(1000);
await record.addOrUpdateProperties({
email: ["new-email@example.com"],
});
const properties = await record.getProperties();
expect(properties.email.stateChangedAt.getTime()).toBeGreaterThan(
start
);
});
});
describe("record property timestamps (array)", () => {
let purchasesProperty: Property;
beforeAll(async () => {
purchasesProperty = await Property.create({
sourceId: source.id,
key: "purchases",
type: "string",
isArray: true,
});
await purchasesProperty.setOptions({ column: "purchases" });
await purchasesProperty.update({ state: "ready" });
});
afterAll(async () => {
if (purchasesProperty) await purchasesProperty.destroy();
});
test("changing a value sets valueChangedAt and confirmedAt", async () => {
const start = new Date().getTime();
await helper.sleep(1000);
await record.addOrUpdateProperties({
purchases: ["hat"],
});
let properties = await record.getProperties();
expect(properties.purchases.valueChangedAt.getTime()).toBeGreaterThan(
start
);
const firstChangeAt = properties.purchases.valueChangedAt.getTime();
expect(properties.purchases.confirmedAt.getTime()).toBeGreaterThan(
start
);
await record.addOrUpdateProperties({
purchases: ["hat", "mushroom"],
});
properties = await record.getProperties();
expect(properties.purchases.valueChangedAt.getTime()).toBeGreaterThan(
firstChangeAt
);
});
test("updating with the same value only sets confirmedAt", async () => {
const start = new Date().getTime();
await helper.sleep(1000);
await record.addOrUpdateProperties({
purchases: ["hat", "mushroom"],
});
const properties = await record.getProperties();
expect(properties.purchases.valueChangedAt.getTime()).toBeLessThan(
start
);
expect(properties.purchases.confirmedAt.getTime()).toBeGreaterThan(
start
);
});
test("changing state sets stateChangedAt", async () => {
await RecordProperty.update(
{ state: "pending" },
{ where: { recordId: record.id } }
);
const start = new Date().getTime();
await helper.sleep(1000);
await record.addOrUpdateProperties({
purchases: ["hat", "mushroom"],
});
const properties = await record.getProperties();
expect(properties.purchases.stateChangedAt.getTime()).toBeGreaterThan(
start
);
});
});
describe("record property lifecycle", () => {
test("it can add properties in bulk with proper timestamps", async () => {
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green"],
userId: [123],
});
const properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
email: ["luigi@example.com"],
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green"],
userId: [123],
});
expect(record.createdAt.getTime()).toBeLessThan(
properties.color.createdAt.getTime()
);
expect(record.updatedAt.getTime()).toBeLessThan(
properties.color.updatedAt.getTime()
);
});
test("it can update an existing property", async () => {
await record.addOrUpdateProperties({
email: ["luigi-again@example.com"],
});
const properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
email: ["luigi-again@example.com"],
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green"],
userId: [123],
});
});
test("it will ignore the property _meta, as it is reserved", async () => {
await record.addOrUpdateProperties({ _meta: ["bla"] });
const properties = await record.getProperties();
expect(simpleRecordValues(properties)._meta).toBeFalsy();
expect(simpleRecordValues(properties).firstName).toEqual(["Luigi"]);
});
test("it can remove an existing property", async () => {
await record.removeProperty("email");
const properties = await record.getProperties();
expect(properties["email"]).toBeUndefined();
expect(simpleRecordValues(properties)).toEqual({
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green"],
userId: [123],
});
});
test("no problems arise when re-adding a deleted property", async () => {
let properties = await record.getProperties();
expect(properties.email).toBeUndefined();
await record.addOrUpdateProperties({ email: ["luigi@example.com"] });
properties = await record.getProperties();
expect(properties.email.values).toEqual(["luigi@example.com"]);
});
test("it will not raise when trying to remove a non-existent property", async () => {
await record.removeProperty("funky");
const properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
email: ["luigi@example.com"],
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green"],
userId: [123],
});
});
test("record properties can be addded by key", async () => {
await record.addOrUpdateProperties({ email: ["luigi@example.com"] });
const properties = await record.getProperties();
expect(simpleRecordValues(properties).email).toEqual([
"luigi@example.com",
]);
});
test("record properties can be addded by id", async () => {
const emailProperty = await Property.findOne({
where: { key: "email" },
});
await record.addOrUpdateProperties({
[emailProperty.id]: ["luigi@example.com"],
});
const properties = await record.getProperties();
expect(simpleRecordValues(properties).email).toEqual([
"luigi@example.com",
]);
});
test("orphan record properties will be removed", async () => {
await record.reload();
const recordProperty = await RecordProperty.create(
{
id: "rule_missing",
recordId: record.id,
propertyId: "missing",
rawValue: "green-hat",
position: 0,
},
//@ts-ignore
{ hooks: false } // we need to skip validations
);
const properties = await record.getProperties(); // does not throw
expect(Object.keys(properties).length).toBe(5);
await expect(recordProperty.reload()).rejects.toThrow(
/does not exist anymore/
);
});
test("deleting the record also deletes the properties", async () => {
const beforeCount = await RecordProperty.count({
where: { recordId: record.id },
});
expect(beforeCount).toBe(5);
await record.destroy();
const afterCount = await RecordProperty.count({
where: { recordId: record.id },
});
expect(afterCount).toBe(0);
});
});
describe("array properties", () => {
let purchasesProperty: Property;
beforeAll(async () => {
const purchasesProperty = await Property.create({
sourceId: source.id,
key: "purchases",
type: "string",
isArray: true,
});
await purchasesProperty.setOptions({ column: "purchases" });
await purchasesProperty.update({ state: "ready" });
});
afterAll(async () => {
if (purchasesProperty) await purchasesProperty.destroy();
});
test("multiple values can be set for array properties and the order is maintained", async () => {
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green"],
userId: [123],
purchases: ["star", "mushroom", "mushroom", "go kart"],
});
const properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
email: ["luigi@example.com"],
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green"],
userId: [123],
purchases: ["star", "mushroom", "mushroom", "go kart"],
});
});
test("when array properties are the same, they will have bumped timestamps", async () => {
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
purchases: ["star", "mushroom", "mushroom", "go kart"],
});
const firstProperties = await record.getProperties();
const firstUpdate = firstProperties.purchases.updatedAt;
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
purchases: ["star", "mushroom", "mushroom", "go kart"],
});
const secondProperties = await record.getProperties();
expect(
secondProperties.purchases.updatedAt.getTime()
).toBeGreaterThanOrEqual(firstUpdate.getTime());
});
test("when any array property has changed, they will all be updated", async () => {
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
purchases: ["star", "mushroom", "mushroom", "go kart"],
});
const firstProperties = await record.getProperties();
const firstUpdate = firstProperties.purchases.updatedAt;
await helper.sleep(1000);
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
purchases: ["go kart"],
});
const secondProperties = await record.getProperties();
expect(
secondProperties.purchases.updatedAt.getTime()
).toBeGreaterThan(firstUpdate.getTime());
});
test("array property length can grow and shrink", async () => {
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
purchases: ["star", "mushroom", "mushroom", "go kart"],
});
const firstProperties = await record.getProperties();
expect(firstProperties.purchases.values).toEqual([
"star",
"mushroom",
"mushroom",
"go kart",
]);
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
purchases: ["star"],
});
const secondProperties = await record.getProperties();
expect(secondProperties.purchases.values).toEqual(["star"]);
await record.addOrUpdateProperties({
email: ["luigi@example.com"],
purchases: ["star", "mushroom", "mushroom"],
});
const thirdProperties = await record.getProperties();
expect(thirdProperties.purchases.values).toEqual([
"star",
"mushroom",
"mushroom",
]);
});
test("other record properties do not accept array values", async () => {
await expect(
record.addOrUpdateProperties({
firstName: ["Luigi"],
lastName: ["Mario"],
color: ["green", "blue", "red"],
userId: [123],
})
).rejects.toThrow(
/cannot set multiple record properties for a non-array property/
);
});
});
});
});
describe("with a group", () => {
let group: Group;
let record: GrouparooRecord;
let app: App;
let source: Source;
let emailProperty: Property;
beforeAll(async () => {
await GrouparooRecord.truncate();
app = await App.create({
name: "test app",
type: "test-plugin-app",
});
await app.setOptions({ fileId: "abc123" });
await app.update({ state: "ready" });
source = await Source.create({
appId: app.id,
name: "test import source",
type: "test-plugin-import",
modelId: model.id,
});
await source.setOptions({ table: "users" });
await source.bootstrapUniqueProperty({
key: "userId",
type: "integer",
mappedColumn: "id",
});
await source.setMapping({ id: "userId" });
await source.update({ state: "ready" });
emailProperty = await Property.create({
sourceId: source.id,
key: "email",
type: "string",
unique: true,
});
await emailProperty.setOptions({ column: "email" });
await emailProperty.update({ state: "ready" });
group = await helper.factories.group({
name: "calculated-group",
});
record = await GrouparooRecord.create({ modelId: model.id });
await record.addOrUpdateProperties({
email: ["mario@example.com"],
});
});
afterAll(async () => {
const members = await group.$get("groupMembers");
for (const m of members) await m.destroy();
await group.destroy();
await source.setMapping({});
for (const property of await Property.findAll()) await property.destroy();
await source.destroy();
await app.destroy();
});
describe("#updateGroupMembership", () => {
test("records can re-calculate their group memberships", async () => {
let members = await group.$get("groupMembers");
expect(members.length).toBe(0);
await group.update({ matchType: "all" });
await group.setRules([
{ key: "email", match: "%@example.com", operation: { op: "like" } },
]);
const groupMemberships = await record.updateGroupMembership();
expect(groupMemberships[group.id]).toBe(true);
members = await group.$get("groupMembers");
expect(members.length).toBe(1);
expect(members[0].recordId).toBe(record.id);
});
});
});
describe("#import", () => {
let emailProperty: Property;
let colorProperty: Property;
let app: App;
let source: Source;
let importResult: { [table: string]: { [mappingValue: string]: any } } = {};
beforeAll(async () => {
await GrouparooRecord.truncate();
plugin.registerPlugin({
name: "test-plugin",
apps: [
{
name: "test-template-app",
displayName: "test-template-app",
options: [],
methods: {
test: async () => {
return { success: true };
},
},
},
],
connections: [
{
name: "import-from-test-template-app",
displayName: "import-from-test-template-app",
description: "a test app connection",
apps: ["test-template-app"],
direction: "import" as "import",
options: [{ key: "table", required: true }],
methods: {
propertyOptions: async () => [],
recordProperty: async ({
property,
sourceOptions,
sourceMapping,
record,
}) => {
const table = sourceOptions.table.toString();
const recordProperties = await record.simplifiedProperties();
const mappingKey = Object.values(sourceMapping)[0];
const mappingVal =
recordProperties[mappingKey].length > 0 &&
recordProperties[mappingKey][0]?.toString();
return mappingVal &&
importResult[table] &&
importResult[table][mappingVal]
? importResult[table][mappingVal][property.key]
: undefined;
},
},
},
],
});
app = await App.create({
name: "test app",
type: "test-template-app",
options: {},
state: "ready",
});
source = await Source.create({
appId: app.id,
name: "test import source",
type: "import-from-test-template-app",
modelId: model.id,
});
await source.bootstrapUniqueProperty({
key: "userId",
type: "integer",
mappedColumn: "id",
});
await source.setOptions({ table: "users" });
await source.setMapping({ id: "userId" });
await source.update({ state: "ready" });
emailProperty = await Property.create({
sourceId: source.id,
key: "email",
type: "string",
unique: true,
});
colorProperty = await Property.create({
sourceId: source.id,
key: "color",
type: "string",
unique: false,
});
await emailProperty.update({ state: "ready" });
await colorProperty.update({ state: "ready" });
});
afterAll(async () => {
await Property.truncate();
await Source.truncate();
await App.truncate();
});
test("it can pull record properties in from all connected apps", async () => {
const record = await GrouparooRecord.create({ modelId: model.id });
await record.addOrUpdateProperties({ userId: [1001] });
await record.addOrUpdateProperties({ email: ["peach@example.com"] });
let properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
userId: [1001],
email: ["peach@example.com"],
color: [null],
});
importResult = {
users: {
"1001": {
userId: [1001],
email: ["peach@example.com"],
color: ["pink"],
},
},
};
await record.import();
properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
userId: [1001],
email: ["peach@example.com"],
color: ["pink"],
});
});
test("importing properties for source that doesn't support direct property imports will keep the old value", async () => {
const record = await GrouparooRecord.create({ modelId: model.id });
await record.addOrUpdateProperties({
userId: [1003],
email: ["bowser@example.com"],
color: ["green"],
});
let properties = await record.getProperties();
expect(Object.keys(properties).sort()).toEqual([
"color",
"email",
"userId",
]);
const connection = api.plugins.plugins.filter(
(p) => p.name === "test-plugin"
)[0].connections[0];
const oldMethod = connection.methods.recordProperty;
delete connection.methods.recordProperty;
await record.import();
properties = await record.getProperties();
const pendingProperties = Object.values(properties).filter(
(v) => v.state === "pending"
);
expect(pendingProperties.length).toBe(0);
expect(simpleRecordValues(properties)).toEqual({
userId: [1003],
email: ["bowser@example.com"],
color: ["green"],
});
connection.methods.recordProperty = oldMethod;
});
test("after importing, all missing properties will have created a null record property", async () => {
const record = await GrouparooRecord.create({ modelId: model.id });
await record.addOrUpdateProperties({ userId: [1002] });
let properties = await record.getProperties();
expect(Object.keys(properties).sort()).toEqual([
"color",
"email",
"userId",
]);
importResult = {
users: {
"1002": {
userId: [1002],
color: ["pink"],
},
},
};
await record.import();
properties = await record.getProperties();
const pendingProperties = Object.values(properties).filter(
(v) => v.state === "pending"
);
expect(pendingProperties.length).toBe(0);
expect(simpleRecordValues(properties)).toEqual({
userId: [1002],
email: [null],
color: ["pink"],
});
});
describe("with a dependent source", () => {
let purchasesTable: Source;
let daisyProfile: GrouparooRecord;
beforeAll(async () => {
purchasesTable = await Source.create({
appId: app.id,
name: "test import purchases source",
type: "import-from-test-template-app",
modelId: model.id,
});
await purchasesTable.setOptions({ table: "purchases" });
await purchasesTable.setMapping({ user_id: "userId" });
await purchasesTable.update({ state: "ready" });
const purchasesCountProperty = await Property.create({
key: "myPurchasesCount",
type: "integer",
unique: false,
sourceId: purchasesTable.id,
});
await purchasesCountProperty.update({ state: "ready" });
});
test("properties from a dependent source can be imported at the same time", async () => {
const record = await GrouparooRecord.create({
modelId: model.id,
});
await record.addOrUpdateProperties({ userId: [1010] });
let properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
userId: [1010],
email: [null],
color: [null],
myPurchasesCount: [null],
});
importResult = {
users: {
"1010": {
userId: [1010],
email: ["daisy@example.com"],
color: ["pink"],
},
},
purchases: {
"1010": {
myPurchasesCount: [2020],
},
},
};
await record.markPending();
await record.import();
properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
userId: [1010],
email: ["daisy@example.com"],
color: ["pink"],
myPurchasesCount: [2020],
});
daisyProfile = record;
});
test("properties from a dependent source can be cleared at the same time if the mapping is gone", async () => {
const record = daisyProfile;
let properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
userId: [1010],
email: ["daisy@example.com"],
color: ["pink"],
myPurchasesCount: [2020],
});
importResult = {
users: {
// daisy is gone
},
purchases: {
"1010": {
// related row still here
myPurchasesCount: [2020],
},
},
};
await record.markPending();
await record.import();
properties = await record.getProperties();
expect(simpleRecordValues(properties)).toEqual({
userId: [null],
email: [null],
color: [null],
myPurchasesCount: [null],
});
});
});
});
describe("merging", () => {
let recordA: GrouparooRecord;
let recordB: GrouparooRecord;
beforeAll(async () => {
await GrouparooRecord.truncate();
await helper.factories.properties(model.id);
// create the records
recordA = await helper.factories.record();
await recordA.import();
recordB = await helper.factories.record();
await recordB.import();
// disable the test plugin import so we can explicitly set record properties
helper.disableTestPluginImport();
});
afterAll(async () => {
await Property.truncate();
await Source.truncate();
await App.truncate();
});
test("the records both have properties", async () => {
const propertiesA = await recordA.getProperties();
const propertiesB = await recordB.getProperties();
expect(Object.keys(propertiesA).length).toBe(9);
expect(Object.keys(propertiesB).length).toBe(9);
});
test("record A has newer email, record B has newer userId, record B has a newer ltv but it is null", async () => {
await recordA.addOrUpdateProperties({ ltv: [123.45] });
await recordB.addOrUpdateProperties({ userId: [100] });
await recordB.addOrUpdateProperties({ firstName: ["fname"] });
// bump the updatedAt time for the email record property, even though they remain null
await helper.sleep(1001);
await recordA.addOrUpdateProperties({
email: ["new-email@example.com"],
});
const propertiesA = await recordA.getProperties();
const propertiesB = await recordB.getProperties();
expect(propertiesA.email.values).toEqual(["new-email@example.com"]);
expect(propertiesA.userId.values).toBeTruthy();
expect(propertiesA.firstName.values).toEqual(["Mario"]);
expect(propertiesA.ltv.values).toEqual([123.45]);
expect(propertiesB.email.values).toBeTruthy();
expect(propertiesB.userId.values).toEqual([100]);
expect(propertiesB.firstName.values).toEqual(["fname"]);
expect(propertiesB.ltv.values).toEqual([100]);
expect(propertiesA.email.updatedAt.getTime()).toBeGreaterThan(
propertiesB.email.updatedAt.getTime()
);
});
test("record A and B each have different purchases", async () => {
await recordA.addOrUpdateProperties({ purchases: ["hat"] });
await recordB.addOrUpdateProperties({ purchases: ["shoe"] });
});
test("merging records moved the properties", async () => {
await RecordOps.merge(recordA, recordB);
const propertiesA = await recordA.getProperties();
const propertiesB = await recordB.getProperties();
expect(Object.keys(propertiesA).length).toBe(9);
expect(Object.keys(propertiesB).length).toBe(0);
});
test("the merged record kept the newer non-null properties", async () => {
const properties = await recordA.getProperties();
expect(properties.email.values).toEqual(["new-email@example.com"]);
expect(properties.userId.values).toEqual([100]);
expect(properties.firstName.values).toEqual(["fname"]);
expect(properties.ltv.values).toEqual([123.45]);
});
test("the merged records should have only kept the array properties of the newest record property", async () => {
// we can't be sure of the array-order for the combined records. A re-import should be deterministic too
const propertiesA = await recordA.getProperties();
expect(propertiesA.purchases.values).toEqual(["shoe"]);
});
test("the merged record is pending", async () => {
await recordA.reload();
expect(recordA.state).toBe("pending");
const properties = await recordA.getProperties();
for (const k in properties) {
expect(properties[k].state).toBe("pending");
}
});
test("after merging the other record is deleted", async () => {
await expect(recordB.reload()).rejects.toThrow(/does not exist/);
const records = await GrouparooRecord.findAll();
expect(records.length).toBe(1);
});
});
});