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
4 changes: 2 additions & 2 deletions packages/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export const setUserId = async (userId: string): Promise<void> => {
await queue.wait();
};

export const setAttribute = async (key: string, value: string): Promise<void> => {
export const setAttribute = async (key: string, value: string | number | Date): Promise<void> => {
queue.add(Attributes.setAttributes, true, { [key]: value });
await queue.wait();
};

export const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
export const setAttributes = async (attributes: Record<string, string | number | Date>): Promise<void> => {
queue.add(Attributes.setAttributes, true, attributes);
await queue.wait();
};
Expand Down
12 changes: 4 additions & 8 deletions packages/react-native/src/lib/common/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,17 @@ export class ApiClient {

async createOrUpdateUser(userUpdateInput: {
userId: string;
attributes?: Record<string, string>;
attributes?: Record<string, string | number>;
}): Promise<Result<CreateOrUpdateUserResponse, ApiErrorResponse>> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record<string, string> = {};
for (const key in userUpdateInput.attributes) {
attributes[key] = String(userUpdateInput.attributes[key]);
}

// Pass attributes as-is to preserve number types
// The backend will use the JS type to determine the attribute data type
return makeRequest(
this.appUrl,
`/api/v2/client/${this.environmentId}/user`,
"POST",
{
userId: userUpdateInput.userId,
attributes,
attributes: userUpdateInput.attributes,
},
this.isDebug
);
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/src/lib/survey/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const track = async (
if (!actionClass) {
return err({
code: "invalid_code",
message: `${code} action unknown. Please add this action in Formbricks first in order to use it in your code.`,
message: `Action with identifier '${code}' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking.`,
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/src/lib/survey/tests/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe("survey/action.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("invalid_code");
expect(result.error.message).toBe(
"invalidCode action unknown. Please add this action in Formbricks first in order to use it in your code."
"Action with identifier 'invalidCode' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking."
);
}
});
Expand Down
30 changes: 28 additions & 2 deletions packages/react-native/src/lib/user/attribute.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,38 @@
import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";

/**
* Sets attributes on the current user/contact.
*
* Attribute types are determined by the JavaScript value type:
* - String values -> string attribute
* - Number values -> number attribute
* - Date objects -> date attribute (converted to ISO string)
* - ISO 8601 date strings -> date attribute
*
* On first write to a new attribute, the type is set based on the JS value type.
* On subsequent writes, the value must match the existing attribute type.
*
* @param attributes - Key-value pairs where values can be strings, numbers, or Date objects
*/
export const setAttributes = async (
attributes: Record<string, string>
attributes: Record<string, string | number | Date>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
// Normalize values: convert Date to ISO string, preserve numbers as numbers
const normalizedAttributes: Record<string, string | number> = {};
for (const [key, value] of Object.entries(attributes)) {
if (value instanceof Date) {
// Date objects become ISO strings (backend will detect as date type)
normalizedAttributes[key] = value.toISOString();
} else {
// Preserve strings as strings, numbers as numbers
normalizedAttributes[key] = value;
}
}

const updateQueue = UpdateQueue.getInstance();
await updateQueue.updateAttributes(attributes);
await updateQueue.updateAttributes(normalizedAttributes);
void updateQueue.processUpdates();
return okVoid();
};
50 changes: 50 additions & 0 deletions packages/react-native/src/lib/user/tests/attribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,55 @@ describe("User Attributes", () => {
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
// The function returns before processUpdates completes due to void operator
});

test("converts Date values to ISO strings", async () => {
const testDate = new Date("2026-01-15T10:30:00.000Z");
const attributes = { createdAt: testDate };

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
createdAt: "2026-01-15T10:30:00.000Z",
});
});

test("preserves number values as numbers", async () => {
const attributes = { age: 25, score: 99.5 };

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
age: 25,
score: 99.5,
});
});

test("preserves string values as strings", async () => {
const attributes = { name: "Alice", role: "admin" };

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
name: "Alice",
role: "admin",
});
});

test("normalizes mixed attribute types correctly", async () => {
const testDate = new Date("2026-06-01T00:00:00.000Z");
const attributes = {
name: "Bob",
age: 30,
joinedAt: testDate,
};

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
name: "Bob",
age: 30,
joinedAt: "2026-06-01T00:00:00.000Z",
});
});
});
});
51 changes: 47 additions & 4 deletions packages/react-native/src/lib/user/tests/update-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ vi.mock("@/lib/common/config", () => ({
},
}));

const { mockLogger } = vi.hoisted(() => ({
mockLogger: {
debug: vi.fn(),
error: vi.fn(),
},
}));

vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
getInstance: vi.fn(() => mockLogger),
},
}));

Expand Down Expand Up @@ -119,6 +123,7 @@ describe("UpdateQueue", () => {

(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});

await updateQueue.updateAttributes({ name: mockAttributes.name });
Expand Down Expand Up @@ -162,4 +167,42 @@ describe("UpdateQueue", () => {
"Formbricks can't set attributes without a userId!"
);
});

test("processUpdates logs error when sendUpdates fails", async () => {
(sendUpdates as Mock).mockResolvedValue({
ok: false,
error: { message: "Server error" },
});

await updateQueue.updateAttributes({ name: mockAttributes.name });

await new Promise((resolve) => {
setTimeout(resolve, 600);
});

await updateQueue.processUpdates();

expect(mockLogger.error).toHaveBeenCalledWith(
"Failed to send updates: Server error"
);
});

test("processUpdates suppresses success log when hasWarnings is true", async () => {
(sendUpdates as Mock).mockResolvedValue({
ok: true,
data: { hasWarnings: true },
});

await updateQueue.updateAttributes({ name: mockAttributes.name });

await new Promise((resolve) => {
setTimeout(resolve, 600);
});

await updateQueue.processUpdates();

expect(mockLogger.debug).not.toHaveBeenCalledWith(
"Updates sent successfully"
);
});
});
77 changes: 75 additions & 2 deletions packages/react-native/src/lib/user/tests/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
Expand Down Expand Up @@ -104,7 +105,7 @@ describe("sendUpdatesToBackend", () => {
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.message).toBe(
"Error updating user with userId user_123"
"Error updating user with userId user_123",
);
}
});
Expand All @@ -128,7 +129,7 @@ describe("sendUpdatesToBackend", () => {
appUrl: mockAppUrl,
environmentId: mockEnvironmentId,
updates: mockUpdates,
})
}),
).rejects.toThrow("Network error");
});
});
Expand All @@ -150,6 +151,7 @@ describe("sendUpdates", () => {

(Logger.getInstance as Mock).mockImplementation(() => ({
debug: vi.fn(),
error: vi.fn(),
}));
});

Expand All @@ -176,6 +178,9 @@ describe("sendUpdates", () => {
});

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.hasWarnings).toBe(false);
}
});

test("handles backend errors", async () => {
Expand Down Expand Up @@ -204,6 +209,74 @@ describe("sendUpdates", () => {
}
});

test("returns hasWarnings true when backend returns errors", async () => {
const mockResponse = {
ok: true,
data: {
state: {
data: {
userId: mockUserId,
attributes: mockAttributes,
},
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
},
errors: ["Attribute 'invalidKey' has an invalid key format"],
},
};

(ApiClient as Mock).mockImplementation(function () {
return { createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse) };
});

const result = await sendUpdates({
updates: { userId: mockUserId, attributes: mockAttributes },
});

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.hasWarnings).toBe(true);
}
});

test("logs backend errors at error level and messages at debug level", async () => {
const mockError = vi.fn();
const mockDebug = vi.fn();
(Logger.getInstance as Mock).mockImplementation(() => ({
debug: mockDebug,
error: mockError,
}));

const mockResponse = {
ok: true,
data: {
state: {
data: {
userId: mockUserId,
attributes: mockAttributes,
},
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
},
messages: ["Email attribute already exists"],
errors: ["Attribute 'badKey' has an invalid format"],
},
};

(ApiClient as Mock).mockImplementation(function () {
return { createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse) };
});

await sendUpdates({
updates: { userId: mockUserId, attributes: mockAttributes },
});

expect(mockError).toHaveBeenCalledWith(
"Attribute 'badKey' has an invalid format",
);
expect(mockDebug).toHaveBeenCalledWith(
"User update message: Email attribute already exists",
);
});

test("handles unexpected errors", async () => {
(ApiClient as Mock).mockImplementation(function () {
return {
Expand Down
Loading