Skip to content

Commit 09d4c84

Browse files
authored
add missing mergeUsers method (#22)
1 parent 9288ffb commit 09d4c84

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

src/client/users.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
GetUserByIdParams,
1818
GetUserFieldsResponse,
1919
GetUserFieldsResponseSchema,
20+
MergeUsersParams,
2021
UpdateEmailParams,
2122
UpdateUserParams,
2223
UpdateUserSubscriptionsParams,
@@ -180,5 +181,17 @@ export function Users<T extends Constructor<BaseIterableClient>>(Base: T) {
180181
const response = await this.client.get("/api/users/getFields");
181182
return validateResponse(response, GetUserFieldsResponseSchema);
182183
}
184+
185+
/**
186+
* Merge two user profiles into one.
187+
* All profile data and events from the source are migrated to the destination.
188+
* Returns an error if the source user does not exist.
189+
*/
190+
async mergeUsers(
191+
params: MergeUsersParams
192+
): Promise<IterableSuccessResponse> {
193+
const response = await this.client.post("/api/users/merge", params);
194+
return validateResponse(response, IterableSuccessResponseSchema);
195+
}
183196
};
184197
}

src/types/users.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,54 @@ export const UpdateUserSubscriptionsParamsSchema = z
277277
export type UpdateUserSubscriptionsParams = z.infer<
278278
typeof UpdateUserSubscriptionsParamsSchema
279279
>;
280+
281+
export const ArrayMergeSchema = z.object({
282+
field: z
283+
.string()
284+
.describe(
285+
"Top-level user profile field containing an array to merge from source to destination"
286+
),
287+
dedupeBy: z
288+
.string()
289+
.optional()
290+
.describe(
291+
"Field on array objects used for de-duplication during merge (only for arrays of objects)"
292+
),
293+
});
294+
295+
export const MergeUsersParamsSchema = z
296+
.object({
297+
sourceEmail: z
298+
.email()
299+
.optional()
300+
.describe("Email of the source user profile to merge from"),
301+
sourceUserId: z
302+
.string()
303+
.optional()
304+
.describe("User ID of the source user profile to merge from"),
305+
destinationEmail: z
306+
.email()
307+
.optional()
308+
.describe("Email of the destination user profile to merge into"),
309+
destinationUserId: z
310+
.string()
311+
.optional()
312+
.describe("User ID of the destination user profile to merge into"),
313+
arrayMerge: z
314+
.array(ArrayMergeSchema)
315+
.optional()
316+
.describe(
317+
"Array fields whose contents should be merged (only custom arrays, not Iterable-managed ones like devices)"
318+
),
319+
})
320+
.refine(
321+
(data) => data.sourceEmail || data.sourceUserId,
322+
"Either sourceEmail or sourceUserId must be provided"
323+
)
324+
.refine(
325+
(data) => data.destinationEmail || data.destinationUserId,
326+
"Either destinationEmail or destinationUserId must be provided"
327+
);
328+
329+
export type MergeUsersParams = z.infer<typeof MergeUsersParamsSchema>;
330+
export type ArrayMerge = z.infer<typeof ArrayMergeSchema>;

tests/integration/users.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,45 @@ describe("User Management Integration Tests", () => {
362362
expect(updateResponse.code).toBe("Success");
363363
});
364364

365+
it("should merge two user profiles", async () => {
366+
const mergeTestId = uniqueId();
367+
const sourceEmail = `merge-source+${mergeTestId}@example.com`;
368+
const destEmail = `merge-dest+${mergeTestId}@example.com`;
369+
370+
try {
371+
// Create source user
372+
await withTimeout(
373+
client.updateUser({
374+
email: sourceEmail,
375+
dataFields: { mergeTest: true, sourceOnly: "from-source" },
376+
})
377+
);
378+
await waitForUserUpdate(client, sourceEmail, { mergeTest: true });
379+
380+
// Create destination user
381+
await withTimeout(
382+
client.updateUser({
383+
email: destEmail,
384+
dataFields: { mergeTest: true, destOnly: "from-dest" },
385+
})
386+
);
387+
await waitForUserUpdate(client, destEmail, { mergeTest: true });
388+
389+
// Merge source into destination
390+
const mergeResponse = await withTimeout(
391+
client.mergeUsers({
392+
sourceEmail,
393+
destinationEmail: destEmail,
394+
})
395+
);
396+
397+
expect(mergeResponse.code).toBe("Success");
398+
} finally {
399+
await cleanupTestUser(client, sourceEmail);
400+
await cleanupTestUser(client, destEmail);
401+
}
402+
});
403+
365404
it("should update user subscriptions by userId", async () => {
366405
// Ensure user exists with userId
367406
await withTimeout(

tests/unit/users.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { IterableClient } from "../../src/client";
1111
import {
1212
GetUserByEmailParamsSchema,
13+
MergeUsersParamsSchema,
1314
UpdateEmailParamsSchema,
1415
UpdateUserParamsSchema,
1516
UpdateUserSubscriptionsParamsSchema,
@@ -267,6 +268,78 @@ describe("User Management", () => {
267268
});
268269
});
269270

271+
describe("mergeUsers", () => {
272+
it("should call merge endpoint with email-based params", async () => {
273+
const params = {
274+
sourceEmail: "source@example.com",
275+
destinationEmail: "dest@example.com",
276+
};
277+
const mockResponse = { data: { code: "Success", msg: "Merged" } };
278+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
279+
280+
const result = await client.mergeUsers(params);
281+
282+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
283+
"/api/users/merge",
284+
params
285+
);
286+
expect(result.code).toBe("Success");
287+
});
288+
289+
it("should call merge endpoint with userId-based params", async () => {
290+
const params = {
291+
sourceUserId: "src-user-123",
292+
destinationUserId: "dst-user-456",
293+
};
294+
const mockResponse = { data: { code: "Success", msg: "Merged" } };
295+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
296+
297+
const result = await client.mergeUsers(params);
298+
299+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
300+
"/api/users/merge",
301+
params
302+
);
303+
expect(result.code).toBe("Success");
304+
});
305+
306+
it("should support mixed email/userId identifiers", async () => {
307+
const params = {
308+
sourceEmail: "source@example.com",
309+
destinationUserId: "dst-user-456",
310+
};
311+
const mockResponse = { data: { code: "Success", msg: "Merged" } };
312+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
313+
314+
await client.mergeUsers(params);
315+
316+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
317+
"/api/users/merge",
318+
params
319+
);
320+
});
321+
322+
it("should support arrayMerge parameter", async () => {
323+
const params = {
324+
sourceEmail: "source@example.com",
325+
destinationEmail: "dest@example.com",
326+
arrayMerge: [
327+
{ field: "purchaseHistory", dedupeBy: "orderId" },
328+
{ field: "tags" },
329+
],
330+
};
331+
const mockResponse = { data: { code: "Success", msg: "Merged" } };
332+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
333+
334+
await client.mergeUsers(params);
335+
336+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
337+
"/api/users/merge",
338+
params
339+
);
340+
});
341+
});
342+
270343
describe("Schema Validation", () => {
271344
it("should validate get_user_by_email parameters", () => {
272345
expect(() =>
@@ -369,5 +442,62 @@ describe("User Management", () => {
369442
})
370443
).not.toThrow();
371444
});
445+
446+
it("should validate mergeUsers parameters", () => {
447+
// Valid: email to email
448+
expect(() =>
449+
MergeUsersParamsSchema.parse({
450+
sourceEmail: "source@example.com",
451+
destinationEmail: "dest@example.com",
452+
})
453+
).not.toThrow();
454+
455+
// Valid: userId to userId
456+
expect(() =>
457+
MergeUsersParamsSchema.parse({
458+
sourceUserId: "src-123",
459+
destinationUserId: "dst-456",
460+
})
461+
).not.toThrow();
462+
463+
// Valid: email to userId (mixed)
464+
expect(() =>
465+
MergeUsersParamsSchema.parse({
466+
sourceEmail: "source@example.com",
467+
destinationUserId: "dst-456",
468+
})
469+
).not.toThrow();
470+
471+
// Valid: with arrayMerge
472+
expect(() =>
473+
MergeUsersParamsSchema.parse({
474+
sourceEmail: "source@example.com",
475+
destinationEmail: "dest@example.com",
476+
arrayMerge: [{ field: "tags" }, { field: "orders", dedupeBy: "id" }],
477+
})
478+
).not.toThrow();
479+
480+
// Invalid: missing source
481+
expect(() =>
482+
MergeUsersParamsSchema.parse({
483+
destinationEmail: "dest@example.com",
484+
})
485+
).toThrow();
486+
487+
// Invalid: missing destination
488+
expect(() =>
489+
MergeUsersParamsSchema.parse({
490+
sourceEmail: "source@example.com",
491+
})
492+
).toThrow();
493+
494+
// Invalid: bad email format
495+
expect(() =>
496+
MergeUsersParamsSchema.parse({
497+
sourceEmail: "not-an-email",
498+
destinationEmail: "dest@example.com",
499+
})
500+
).toThrow();
501+
});
372502
});
373503
});

0 commit comments

Comments
 (0)