diff --git a/src/client/users.ts b/src/client/users.ts index c04b476..67179dd 100644 --- a/src/client/users.ts +++ b/src/client/users.ts @@ -17,6 +17,7 @@ import { GetUserByIdParams, GetUserFieldsResponse, GetUserFieldsResponseSchema, + MergeUsersParams, UpdateEmailParams, UpdateUserParams, UpdateUserSubscriptionsParams, @@ -180,5 +181,17 @@ export function Users>(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 { + const response = await this.client.post("/api/users/merge", params); + return validateResponse(response, IterableSuccessResponseSchema); + } }; } diff --git a/src/types/users.ts b/src/types/users.ts index f568f9e..3d7b9ac 100644 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -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; +export type ArrayMerge = z.infer; diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts index c6c954f..81da9ad 100644 --- a/tests/integration/users.test.ts +++ b/tests/integration/users.test.ts @@ -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( diff --git a/tests/unit/users.test.ts b/tests/unit/users.test.ts index f076d8e..6c1bbde 100644 --- a/tests/unit/users.test.ts +++ b/tests/unit/users.test.ts @@ -10,6 +10,7 @@ import { import { IterableClient } from "../../src/client"; import { GetUserByEmailParamsSchema, + MergeUsersParamsSchema, UpdateEmailParamsSchema, UpdateUserParamsSchema, UpdateUserSubscriptionsParamsSchema, @@ -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(() => @@ -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(); + }); }); });