Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/client/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
GetUserByIdParams,
GetUserFieldsResponse,
GetUserFieldsResponseSchema,
MergeUsersParams,
UpdateEmailParams,
UpdateUserParams,
UpdateUserSubscriptionsParams,
Expand Down Expand Up @@ -180,5 +181,17 @@ export function Users<T extends Constructor<BaseIterableClient>>(Base: T) {
const response = await this.client.get("/api/users/getFields");
return validateResponse(response, GetUserFieldsResponseSchema);
}

/**
* Merge two user profiles into one.
* All profile data and events from the source are migrated to the destination.
* Returns an error if the source user does not exist.
*/
async mergeUsers(
params: MergeUsersParams
): Promise<IterableSuccessResponse> {
const response = await this.client.post("/api/users/merge", params);
return validateResponse(response, IterableSuccessResponseSchema);
}
};
}
51 changes: 51 additions & 0 deletions src/types/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,54 @@ export const UpdateUserSubscriptionsParamsSchema = z
export type UpdateUserSubscriptionsParams = z.infer<
typeof UpdateUserSubscriptionsParamsSchema
>;

export const ArrayMergeSchema = z.object({
field: z
.string()
.describe(
"Top-level user profile field containing an array to merge from source to destination"
),
dedupeBy: z
.string()
.optional()
.describe(
"Field on array objects used for de-duplication during merge (only for arrays of objects)"
),
});

export const MergeUsersParamsSchema = z
.object({
sourceEmail: z
.email()
.optional()
.describe("Email of the source user profile to merge from"),
sourceUserId: z
.string()
.optional()
.describe("User ID of the source user profile to merge from"),
destinationEmail: z
.email()
.optional()
.describe("Email of the destination user profile to merge into"),
destinationUserId: z
.string()
.optional()
.describe("User ID of the destination user profile to merge into"),
arrayMerge: z
.array(ArrayMergeSchema)
.optional()
.describe(
"Array fields whose contents should be merged (only custom arrays, not Iterable-managed ones like devices)"
),
})
.refine(
(data) => data.sourceEmail || data.sourceUserId,
"Either sourceEmail or sourceUserId must be provided"
)
.refine(
(data) => data.destinationEmail || data.destinationUserId,
"Either destinationEmail or destinationUserId must be provided"
);

export type MergeUsersParams = z.infer<typeof MergeUsersParamsSchema>;
export type ArrayMerge = z.infer<typeof ArrayMergeSchema>;
39 changes: 39 additions & 0 deletions tests/integration/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,45 @@ describe("User Management Integration Tests", () => {
expect(updateResponse.code).toBe("Success");
});

it("should merge two user profiles", async () => {
const mergeTestId = uniqueId();
const sourceEmail = `merge-source+${mergeTestId}@example.com`;
const destEmail = `merge-dest+${mergeTestId}@example.com`;

try {
// Create source user
await withTimeout(
client.updateUser({
email: sourceEmail,
dataFields: { mergeTest: true, sourceOnly: "from-source" },
})
);
await waitForUserUpdate(client, sourceEmail, { mergeTest: true });

// Create destination user
await withTimeout(
client.updateUser({
email: destEmail,
dataFields: { mergeTest: true, destOnly: "from-dest" },
})
);
await waitForUserUpdate(client, destEmail, { mergeTest: true });

// Merge source into destination
const mergeResponse = await withTimeout(
client.mergeUsers({
sourceEmail,
destinationEmail: destEmail,
})
);

expect(mergeResponse.code).toBe("Success");
} finally {
await cleanupTestUser(client, sourceEmail);
await cleanupTestUser(client, destEmail);
}
});

it("should update user subscriptions by userId", async () => {
// Ensure user exists with userId
await withTimeout(
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { IterableClient } from "../../src/client";
import {
GetUserByEmailParamsSchema,
MergeUsersParamsSchema,
UpdateEmailParamsSchema,
UpdateUserParamsSchema,
UpdateUserSubscriptionsParamsSchema,
Expand Down Expand Up @@ -267,6 +268,78 @@ describe("User Management", () => {
});
});

describe("mergeUsers", () => {
it("should call merge endpoint with email-based params", async () => {
const params = {
sourceEmail: "source@example.com",
destinationEmail: "dest@example.com",
};
const mockResponse = { data: { code: "Success", msg: "Merged" } };
mockAxiosInstance.post.mockResolvedValue(mockResponse);

const result = await client.mergeUsers(params);

expect(mockAxiosInstance.post).toHaveBeenCalledWith(
"/api/users/merge",
params
);
expect(result.code).toBe("Success");
});

it("should call merge endpoint with userId-based params", async () => {
const params = {
sourceUserId: "src-user-123",
destinationUserId: "dst-user-456",
};
const mockResponse = { data: { code: "Success", msg: "Merged" } };
mockAxiosInstance.post.mockResolvedValue(mockResponse);

const result = await client.mergeUsers(params);

expect(mockAxiosInstance.post).toHaveBeenCalledWith(
"/api/users/merge",
params
);
expect(result.code).toBe("Success");
});

it("should support mixed email/userId identifiers", async () => {
const params = {
sourceEmail: "source@example.com",
destinationUserId: "dst-user-456",
};
const mockResponse = { data: { code: "Success", msg: "Merged" } };
mockAxiosInstance.post.mockResolvedValue(mockResponse);

await client.mergeUsers(params);

expect(mockAxiosInstance.post).toHaveBeenCalledWith(
"/api/users/merge",
params
);
});

it("should support arrayMerge parameter", async () => {
const params = {
sourceEmail: "source@example.com",
destinationEmail: "dest@example.com",
arrayMerge: [
{ field: "purchaseHistory", dedupeBy: "orderId" },
{ field: "tags" },
],
};
const mockResponse = { data: { code: "Success", msg: "Merged" } };
mockAxiosInstance.post.mockResolvedValue(mockResponse);

await client.mergeUsers(params);

expect(mockAxiosInstance.post).toHaveBeenCalledWith(
"/api/users/merge",
params
);
});
});

describe("Schema Validation", () => {
it("should validate get_user_by_email parameters", () => {
expect(() =>
Expand Down Expand Up @@ -369,5 +442,62 @@ describe("User Management", () => {
})
).not.toThrow();
});

it("should validate mergeUsers parameters", () => {
// Valid: email to email
expect(() =>
MergeUsersParamsSchema.parse({
sourceEmail: "source@example.com",
destinationEmail: "dest@example.com",
})
).not.toThrow();

// Valid: userId to userId
expect(() =>
MergeUsersParamsSchema.parse({
sourceUserId: "src-123",
destinationUserId: "dst-456",
})
).not.toThrow();

// Valid: email to userId (mixed)
expect(() =>
MergeUsersParamsSchema.parse({
sourceEmail: "source@example.com",
destinationUserId: "dst-456",
})
).not.toThrow();

// Valid: with arrayMerge
expect(() =>
MergeUsersParamsSchema.parse({
sourceEmail: "source@example.com",
destinationEmail: "dest@example.com",
arrayMerge: [{ field: "tags" }, { field: "orders", dedupeBy: "id" }],
})
).not.toThrow();

// Invalid: missing source
expect(() =>
MergeUsersParamsSchema.parse({
destinationEmail: "dest@example.com",
})
).toThrow();

// Invalid: missing destination
expect(() =>
MergeUsersParamsSchema.parse({
sourceEmail: "source@example.com",
})
).toThrow();

// Invalid: bad email format
expect(() =>
MergeUsersParamsSchema.parse({
sourceEmail: "not-an-email",
destinationEmail: "dest@example.com",
})
).toThrow();
});
});
});
Loading