chore: migrate react-native to its own repo (#5583)

This commit is contained in:
Matti Nannt
2025-04-29 22:25:15 +02:00
committed by GitHub
parent a760a3c341
commit faf6c2d062
69 changed files with 1 additions and 5929 deletions

View File

@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
Portions of this software are licensed as follows:
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All content that resides under the "packages/js/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.

View File

@@ -1,2 +0,0 @@
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1

View File

@@ -1,7 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/react.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

View File

@@ -1,35 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo

View File

@@ -1,35 +0,0 @@
{
"expo": {
"android": {
"adaptiveIcon": {
"backgroundColor": "#ffffff",
"foregroundImage": "./assets/adaptive-icon.png"
}
},
"assetBundlePatterns": ["**/*"],
"icon": "./assets/icon.png",
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Take pictures for certain activities.",
"NSMicrophoneUsageDescription": "Need microphone access for recording videos.",
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities."
},
"supportsTablet": true
},
"jsEngine": "hermes",
"name": "react-native-demo",
"newArchEnabled": true,
"orientation": "portrait",
"slug": "react-native-demo",
"splash": {
"backgroundColor": "#ffffff",
"image": "./assets/splash.png",
"resizeMode": "contain"
},
"userInterfaceStyle": "light",
"version": "1.0.0",
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,6 +0,0 @@
module.exports = function babel(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

View File

@@ -1,7 +0,0 @@
import { registerRootComponent } from "expo";
import { LogBox } from "react-native";
import App from "./src/app";
registerRootComponent(App);
LogBox.ignoreAllLogs();

View File

@@ -1,21 +0,0 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
const workspaceRoot = path.resolve(__dirname, "../..");
const projectRoot = __dirname;
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages, and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;
module.exports = config;

View File

@@ -1,30 +0,0 @@
{
"name": "@formbricks/demo-react-native",
"version": "1.0.0",
"main": "./index.js",
"scripts": {
"dev": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject",
"clean": "rimraf .turbo node_modules .expo"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/react-native": "workspace:*",
"@react-native-async-storage/async-storage": "2.1.0",
"expo": "52.0.28",
"expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.6",
"react-native-webview": "13.12.5"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@types/react": "18.3.18",
"typescript": "5.7.2"
},
"private": true
}

View File

@@ -1,117 +0,0 @@
import { StatusBar } from "expo-status-bar";
import React, { type JSX } from "react";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
import Formbricks, {
logout,
setAttribute,
setAttributes,
setLanguage,
setUserId,
track,
} from "@formbricks/react-native";
LogBox.ignoreAllLogs();
export default function App(): JSX.Element {
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
}
if (!process.env.EXPO_PUBLIC_APP_URL) {
throw new Error("EXPO_PUBLIC_APP_URL is required");
}
return (
<View style={styles.container}>
<Text>Formbricks React Native SDK Demo</Text>
<View
style={{
display: "flex",
flexDirection: "column",
gap: 10,
}}>
<Button
title="Trigger Code Action"
onPress={() => {
track("code").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error tracking event:", error);
});
}}
/>
<Button
title="Set User Id"
onPress={() => {
setUserId("random-user-id").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user id:", error);
});
}}
/>
<Button
title="Set User Attributess (multiple)"
onPress={() => {
setAttributes({
testAttr: "attr-test",
testAttr2: "attr-test-2",
testAttr3: "attr-test-3",
testAttr4: "attr-test-4",
}).catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Set User Attributes (single)"
onPress={() => {
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Logout"
onPress={() => {
logout().catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error logging out:", error);
});
}}
/>
<Button
title="Set Language (de)"
onPress={() => {
setLanguage("de").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting language:", error);
});
}}
/>
</View>
<StatusBar style="auto" />
<Formbricks
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center",
},
});

View File

@@ -1,6 +0,0 @@
{
"compilerOptions": {
"strict": true
},
"extends": "expo/tsconfig.base"
}

View File

@@ -1,11 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/react.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"import/no-relative-packages": "off",
},
};

View File

@@ -1,28 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# misc
.DS_Store
*.pem
# build
dist
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2024 Formbricks GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,38 +0,0 @@
# Formbricks React Native SDK
[![npm package](https://img.shields.io/npm/v/@formbricks/react-native?style=flat-square)](https://www.npmjs.com/package/@formbricks/react-native)
[![MIT License](https://img.shields.io/badge/License-MIT-red.svg?style=flat-square)](https://opensource.org/licenses/MIT)
Please see [Formbricks Docs](https://formbricks.com/docs).
Specifically, [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
## What is Formbricks
Formbricks is your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 For more information please check out [formbricks.com](https://formbricks.com).
## How to use this library
1. Install the Formbricks package inside your project using npm:
```bash
npm install @formbricks/react-native
```
1. Import Formbricks and initialize the widget in your main component (e.g., App.tsx or App.js):
```javascript
import Formbricks, { track } from "@formbricks/react-native";
export default function App() {
return (
<View>
{/* Your app code */}
<Formbricks appUrl="https://app.formbricks.com" environmentId="your-environment-id" />
</View>
);
}
```
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Connections instructions** in the Formbricks **Configuration** pages.
For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).

View File

@@ -1,64 +0,0 @@
{
"name": "@formbricks/react-native",
"version": "2.1.2",
"license": "MIT",
"description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
"type": "git",
"url": "https://github.com/formbricks/formbricks"
},
"keywords": [
"Formbricks",
"surveys",
"experience management",
"react native",
"sdk"
],
"author": "Formbricks <hola@formbricks.com>",
"sideEffects": false,
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsc && vite build",
"build:dev": "tsc && vite build --mode dev",
"lint": "eslint src --ext .ts,.js,.tsx,.jsx",
"dev": "vite build --watch --mode dev",
"clean": "rimraf .turbo node_modules dist .turbo",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@react-native-community/netinfo": "11.4.1",
"zod": "3.24.1"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@types/react": "18.3.1",
"@vitest/coverage-v8": "3.1.1",
"react": "18.3.1",
"react-native": "0.74.5",
"terser": "5.37.0",
"vite": "6.2.5",
"vite-plugin-dts": "4.5.3",
"vitest": "3.1.1"
},
"peerDependencies": {
"@react-native-async-storage/async-storage": ">=2.1.0",
"react": ">=16.8.0",
"react-native": ">=0.60.0",
"react-native-webview": ">=13.0.0"
}
}

View File

@@ -1,43 +0,0 @@
import { SurveyWebView } from "@/components/survey-web-view";
import { Logger } from "@/lib/common/logger";
import { setup } from "@/lib/common/setup";
import { SurveyStore } from "@/lib/survey/store";
import React, { useCallback, useEffect, useSyncExternalStore } from "react";
interface FormbricksProps {
appUrl: string;
environmentId: string;
}
const surveyStore = SurveyStore.getInstance();
const logger = Logger.getInstance();
export function Formbricks({ appUrl, environmentId }: FormbricksProps): React.JSX.Element | null {
// initializes sdk
useEffect(() => {
const setupFormbricks = async (): Promise<void> => {
try {
await setup({
environmentId,
appUrl,
});
} catch {
logger.debug("Initialization failed");
}
};
setupFormbricks().catch(() => {
logger.debug("Initialization error");
});
}, [environmentId, appUrl]);
const subscribe = useCallback((callback: () => void) => {
const unsubscribe = surveyStore.subscribe(callback);
return unsubscribe;
}, []);
const getSnapshot = useCallback(() => surveyStore.getSurvey(), []);
const survey = useSyncExternalStore(subscribe, getSnapshot);
return survey ? <SurveyWebView survey={survey} /> : null;
}

View File

@@ -1,266 +0,0 @@
/* eslint-disable no-console -- debugging*/
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils";
import { SurveyStore } from "@/lib/survey/store";
import { type TEnvironmentStateSurvey, type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config";
import type { SurveyContainerProps } from "@/types/survey";
import React, { type JSX, useEffect, useRef, useState } from "react";
import { Modal } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
logger.configure({ logLevel: "debug" });
const surveyStore = SurveyStore.getInstance();
interface SurveyWebViewProps {
survey: TEnvironmentStateSurvey;
}
export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | undefined {
const webViewRef = useRef(null);
const [isSurveyRunning, setIsSurveyRunning] = useState(false);
const [showSurvey, setShowSurvey] = useState(false);
const project = appConfig.get().environment.data.project;
const language = appConfig.get().user.data.language;
const styling = getStyling(project, survey);
const isBrandingEnabled = project.inAppSurveyBranding;
const isMultiLanguageSurvey = survey.languages.length > 1;
const [languageCode, setLanguageCode] = useState("default");
useEffect(() => {
if (isMultiLanguageSurvey) {
const displayLanguage = getLanguageCode(survey, language);
if (!displayLanguage) {
logger.debug(`Survey "${survey.name}" is not available in specified language.`);
setIsSurveyRunning(false);
setShowSurvey(false);
surveyStore.resetSurvey();
return;
}
setLanguageCode(displayLanguage);
setIsSurveyRunning(true);
} else {
setIsSurveyRunning(true);
}
}, [isMultiLanguageSurvey, language, survey]);
useEffect(() => {
if (!isSurveyRunning) {
setShowSurvey(false);
return;
}
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${String(survey.delay)} seconds`);
const timerId = setTimeout(() => {
setShowSurvey(true);
}, survey.delay * 1000);
return () => {
clearTimeout(timerId);
};
}
setShowSurvey(true);
}, [survey.delay, isSurveyRunning, survey.name]);
const onCloseSurvey = (): void => {
const { environment: environmentState, user: personState } = appConfig.get();
const filteredSurveys = filterSurveys(environmentState, personState);
appConfig.update({
...appConfig.get(),
environment: environmentState,
user: personState,
filteredSurveys,
});
surveyStore.resetSurvey();
setShowSurvey(false);
};
const surveyPlacement = survey.projectOverwrites?.placement ?? project.placement;
const clickOutside = survey.projectOverwrites?.clickOutsideClose ?? project.clickOutsideClose;
const darkOverlay = survey.projectOverwrites?.darkOverlay ?? project.darkOverlay;
return (
<Modal
animationType="slide"
visible={showSurvey}
transparent
onRequestClose={() => {
setShowSurvey(false);
setIsSurveyRunning(false);
}}>
<WebView
ref={webViewRef}
originWhitelist={["*"]}
source={{
html: renderHtml({
environmentId: appConfig.get().environmentId,
contactId: appConfig.get().user.data.contactId ?? undefined,
survey,
isBrandingEnabled,
styling,
languageCode,
placement: surveyPlacement,
appUrl: appConfig.get().appUrl,
clickOutside: surveyPlacement === "center" ? clickOutside : true,
darkOverlay,
getSetIsResponseSendingFinished: (_f: (value: boolean) => void) => undefined,
isWebEnvironment: false,
}),
}}
style={{ backgroundColor: "transparent" }}
contentMode="mobile"
javaScriptEnabled
domStorageEnabled
startInLoadingState
mixedContentMode="always"
allowFileAccess
allowFileAccessFromFileURLs
allowUniversalAccessFromFileURLs
onShouldStartLoadWithRequest={(event) => {
// prevent webview from redirecting if users taps on formbricks link.
if (event.url.startsWith("https://formbricks")) {
return false;
}
return true;
}}
onMessage={(event: WebViewMessageEvent) => {
try {
const { data } = event.nativeEvent;
const unvalidatedMessage = JSON.parse(data) as { type: string; data: unknown };
// debugger
if (unvalidatedMessage.type === "Console") {
console.info(`[Console] ${JSON.stringify(unvalidatedMessage.data)}`);
}
const validatedMessage = ZJsRNWebViewOnMessageData.safeParse(unvalidatedMessage);
if (!validatedMessage.success) {
logger.error("Error parsing message from WebView.");
return;
}
const { onDisplayCreated, onResponseCreated, onClose } = validatedMessage.data;
if (onDisplayCreated) {
const existingDisplays = appConfig.get().user.data.displays;
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
const displays = [...existingDisplays, newDisplay];
const previousConfig = appConfig.get();
const updatedPersonState = {
...previousConfig.user,
data: {
...previousConfig.user.data,
displays,
lastDisplayAt: new Date(),
},
};
const filteredSurveys = filterSurveys(previousConfig.environment, updatedPersonState);
appConfig.update({
...previousConfig,
environment: previousConfig.environment,
user: updatedPersonState,
filteredSurveys,
});
}
if (onResponseCreated) {
const responses = appConfig.get().user.data.responses;
const newPersonState: TUserState = {
...appConfig.get().user,
data: {
...appConfig.get().user.data,
responses: [...responses, survey.id],
},
};
const filteredSurveys = filterSurveys(appConfig.get().environment, newPersonState);
appConfig.update({
...appConfig.get(),
environment: appConfig.get().environment,
user: newPersonState,
filteredSurveys,
});
}
if (onClose) {
onCloseSurvey();
}
} catch (error) {
logger.error(`Error handling WebView message: ${error as string}`);
}
}}
/>
</Modal>
);
}
const renderHtml = (options: Partial<SurveyContainerProps> & { appUrl?: string }): string => {
return `
<!doctype html>
<html>
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
<head>
<title>Formbricks WebView Survey</title>
</head>
<body style="overflow: hidden; height: 100vh; margin: 0;">
</body>
<script type="text/javascript">
const consoleLog = (type, log) => window.ReactNativeWebView.postMessage(JSON.stringify({'type': 'Console', 'data': {'type': type, 'log': log}}));
console = {
log: (log) => consoleLog('log', log),
debug: (log) => consoleLog('debug', log),
info: (log) => consoleLog('info', log),
warn: (log) => consoleLog('warn', log),
error: (log) => consoleLog('error', log),
};
function onClose() {
window.ReactNativeWebView.postMessage(JSON.stringify({ onClose: true }));
};
function onDisplayCreated() {
window.ReactNativeWebView.postMessage(JSON.stringify({ onDisplayCreated: true }));
};
function onResponseCreated() {
window.ReactNativeWebView.postMessage(JSON.stringify({ onResponseCreated: true }));
};
function loadSurvey() {
const options = ${JSON.stringify(options)};
const surveyProps = {
...options,
onDisplayCreated,
onResponseCreated,
onClose,
};
window.formbricksSurveys.renderSurvey(surveyProps);
}
const script = document.createElement("script");
script.src = "${options.appUrl ?? "http://localhost:3000"}/js/surveys.umd.cjs";
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
};
document.head.appendChild(script);
</script>
</html>
`;
};

View File

@@ -1,42 +0,0 @@
import { Formbricks } from "@/components/formbricks";
import { CommandQueue } from "@/lib/common/command-queue";
import { Logger } from "@/lib/common/logger";
import * as Actions from "@/lib/survey/action";
import * as Attributes from "@/lib/user/attribute";
import * as User from "@/lib/user/user";
const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
export const track = async (name: string): Promise<void> => {
queue.add(Actions.track, true, name);
await queue.wait();
};
export const setUserId = async (userId: string): Promise<void> => {
queue.add(User.setUserId, true, userId);
await queue.wait();
};
export const setAttribute = async (key: string, value: string): Promise<void> => {
queue.add(Attributes.setAttributes, true, { [key]: value });
await queue.wait();
};
export const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
queue.add(Attributes.setAttributes, true, attributes);
await queue.wait();
};
export const setLanguage = async (language: string): Promise<void> => {
queue.add(Attributes.setAttributes, true, { language });
await queue.wait();
};
export const logout = async (): Promise<void> => {
queue.add(User.logout, true);
await queue.wait();
};
export default Formbricks;

View File

@@ -1,102 +0,0 @@
import { wrapThrowsAsync } from "@/lib/common/utils";
import { ApiResponse, ApiSuccessResponse, CreateOrUpdateUserResponse } from "@/types/api";
import { TEnvironmentState } from "@/types/config";
import { ApiErrorResponse, Result, err, ok } from "@/types/error";
export const makeRequest = async <T>(
appUrl: string,
endpoint: string,
method: "GET" | "POST" | "PUT" | "DELETE",
data?: unknown,
isDebug = false
): Promise<Result<T, ApiErrorResponse>> => {
const url = new URL(appUrl + endpoint);
const body = data ? JSON.stringify(data) : undefined;
const res = await wrapThrowsAsync(fetch)(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
...(isDebug && { "Cache-Control": "no-cache" }),
},
body,
});
if (!res.ok) {
return err({
code: "network_error",
status: 500,
message: "Something went wrong",
});
}
const response = res.data;
const json = (await response.json()) as ApiResponse;
if (!response.ok) {
const errorResponse = json as ApiErrorResponse;
return err({
code: errorResponse.code === "forbidden" ? "forbidden" : "network_error",
status: response.status,
message: errorResponse.message || "Something went wrong",
url,
...(Object.keys(errorResponse.details ?? {}).length > 0 && { details: errorResponse.details }),
});
}
const successResponse = json as ApiSuccessResponse<T>;
return ok(successResponse.data);
};
// Simple API client using fetch
export class ApiClient {
private appUrl: string;
private environmentId: string;
private isDebug: boolean;
constructor({
appUrl,
environmentId,
isDebug = false,
}: {
appUrl: string;
environmentId: string;
isDebug: boolean;
}) {
this.appUrl = appUrl;
this.environmentId = environmentId;
this.isDebug = isDebug;
}
async createOrUpdateUser(userUpdateInput: {
userId: string;
attributes?: Record<string, string>;
}): 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]);
}
return makeRequest(
this.appUrl,
`/api/v2/client/${this.environmentId}/user`,
"POST",
{
userId: userUpdateInput.userId,
attributes,
},
this.isDebug
);
}
async getEnvironmentState(): Promise<Result<TEnvironmentState, ApiErrorResponse>> {
return makeRequest(
this.appUrl,
`/api/v1/client/${this.environmentId}/environment`,
"GET",
undefined,
this.isDebug
);
}
}

View File

@@ -1,73 +0,0 @@
/* eslint-disable no-console -- we need to log global errors */
import { checkSetup } from "@/lib/common/setup";
import { wrapThrowsAsync } from "@/lib/common/utils";
import type { Result } from "@/types/error";
export class CommandQueue {
private queue: {
command: (...args: any[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
checkSetup: boolean;
commandArgs: any[];
}[] = [];
private running = false;
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise<void> | null = null;
public add<A>(
command: (...args: A[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>,
shouldCheckSetup = true,
...args: A[]
): void {
this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
this.resolvePromise = resolve;
void this.run();
});
}
}
public async wait(): Promise<void> {
if (this.running) {
await this.commandPromise;
}
}
private async run(): Promise<void> {
this.running = true;
while (this.queue.length > 0) {
const currentItem = this.queue.shift();
if (!currentItem) continue;
// make sure formbricks is setup
if (currentItem.checkSetup) {
// call different function based on package type
const setupResult = checkSetup();
if (!setupResult.ok) {
continue;
}
}
const executeCommand = async (): Promise<Result<void, unknown>> => {
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result<void, unknown>;
};
const result = await wrapThrowsAsync(executeCommand)();
if (!result.ok) {
console.error("🧱 Formbricks - Global error: ", result.error);
} else if (!result.data.ok) {
console.error("🧱 Formbricks - Global error: ", result.data.error);
}
}
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();
this.resolvePromise = null;
this.commandPromise = null;
}
}
}

View File

@@ -1,92 +0,0 @@
/* eslint-disable no-console -- Required for error logging */
import { AsyncStorage } from "@/lib/common/storage";
import { wrapThrowsAsync } from "@/lib/common/utils";
import type { TConfig, TConfigUpdateInput } from "@/types/config";
import { type Result, err, ok } from "@/types/error";
export const RN_ASYNC_STORAGE_KEY = "formbricks-react-native";
export class RNConfig {
private static instance: RNConfig | null = null;
private config: TConfig | null = null;
private constructor() {
this.loadFromStorage()
.then((localConfig) => {
if (localConfig.ok) {
this.config = localConfig.data;
}
})
.catch((e: unknown) => {
console.error("Error loading config from storage", e);
});
}
static getInstance(): RNConfig {
if (!RNConfig.instance) {
RNConfig.instance = new RNConfig();
}
return RNConfig.instance;
}
public update(newConfig: TConfigUpdateInput): void {
this.config = {
...this.config,
...newConfig,
status: {
value: newConfig.status?.value ?? "success",
expiresAt: newConfig.status?.expiresAt ?? null,
},
};
void this.saveToStorage();
}
public get(): TConfig {
if (!this.config) {
throw new Error("config is null, maybe the init function was not called?");
}
return this.config;
}
public async loadFromStorage(): Promise<Result<TConfig>> {
try {
const savedConfig = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig) as TConfig;
// check if the config has expired
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- need to check if expiresAt is set
parsedConfig.environment.expiresAt &&
new Date(parsedConfig.environment.expiresAt) <= new Date()
) {
return err(new Error("Config in local storage has expired"));
}
return ok(parsedConfig);
}
} catch {
return err(new Error("No or invalid config in local storage"));
}
return err(new Error("No or invalid config in local storage"));
}
private async saveToStorage(): Promise<Result<void>> {
return wrapThrowsAsync(async () => {
await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(this.config));
})();
}
// reset the config
public async resetConfig(): Promise<Result<void>> {
this.config = null;
return wrapThrowsAsync(async () => {
await AsyncStorage.removeItem(RN_ASYNC_STORAGE_KEY);
})();
}
}

View File

@@ -1,32 +0,0 @@
import {
addEnvironmentStateExpiryCheckListener,
clearEnvironmentStateExpiryCheckListener,
} from "@/lib/environment/state";
import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
let areRemoveEventListenersAdded = false;
export const addEventListeners = (): void => {
addEnvironmentStateExpiryCheckListener();
addUserStateExpiryCheckListener();
};
export const addCleanupEventListeners = (): void => {
if (areRemoveEventListenersAdded) return;
clearEnvironmentStateExpiryCheckListener();
clearUserStateExpiryCheckListener();
areRemoveEventListenersAdded = true;
};
export const removeCleanupEventListeners = (): void => {
if (!areRemoveEventListenersAdded) return;
clearEnvironmentStateExpiryCheckListener();
clearUserStateExpiryCheckListener();
areRemoveEventListenersAdded = false;
};
export const removeAllEventListeners = (): void => {
clearEnvironmentStateExpiryCheckListener();
clearUserStateExpiryCheckListener();
removeCleanupEventListeners();
};

View File

@@ -1,127 +0,0 @@
/* eslint-disable no-console -- used for error logging */
import { type TUploadFileConfig, type TUploadFileResponse } from "@/types/storage";
export class StorageAPI {
private appUrl: string;
private environmentId: string;
constructor(appUrl: string, environmentId: string) {
this.appUrl = appUrl;
this.environmentId = environmentId;
}
async uploadFile(
file: {
type: string;
name: string;
base64: string;
},
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
): Promise<string> {
if (!file.name || !file.type || !file.base64) {
throw new Error(`Invalid file object`);
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions,
surveyId,
};
const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${String(response.status)}`);
}
const json = (await response.json()) as TUploadFileResponse;
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let localUploadDetails: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
surveyId: surveyId ?? "",
signature,
timestamp: String(timestamp),
uuid,
};
}
const formData: Record<string, string> = {};
const formDataForS3 = new FormData();
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formDataForS3.append(key, presignedFields[key]);
});
try {
const buffer = Buffer.from(file.base64.split(",")[1], "base64");
const blob = new Blob([buffer], { type: file.type });
formDataForS3.append("file", blob);
} catch (buffErr) {
console.error({ buffErr });
throw new Error("Error uploading file");
}
}
formData.fileBase64String = file.base64;
let uploadResponse: Response = {} as Response;
const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl);
try {
uploadResponse = await fetch(signedUrlCopy, {
method: "POST",
body: presignedFields
? formDataForS3
: JSON.stringify({
...formData,
...localUploadDetails,
}),
});
} catch (err) {
console.error("Error uploading file", err);
}
if (!uploadResponse.ok) {
// if local storage is used, we'll use the json response:
if (signingData) {
const uploadJson = (await uploadResponse.json()) as { message: string };
const error = new Error(uploadJson.message);
error.name = "FileTooLargeError";
throw error;
}
// if s3 is used, we'll use the text response:
const errorText = await uploadResponse.text();
if (presignedFields && errorText.includes("EntityTooLarge")) {
const error = new Error("File size exceeds the size limit for your plan");
error.name = "FileTooLargeError";
throw error;
}
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
}
return fileUrl;
}
}

View File

@@ -1,50 +0,0 @@
/* eslint-disable no-console -- Required for logging */
type LogLevel = "debug" | "error";
interface LoggerConfig {
logLevel?: LogLevel;
}
export class Logger {
private static instance: Logger | undefined;
private logLevel: LogLevel = "error";
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
configure(config: LoggerConfig): void {
if (config.logLevel !== undefined) {
this.logLevel = config.logLevel;
}
}
private logger(message: string, level: LogLevel): void {
if (level === "debug" && this.logLevel !== "debug") {
return;
}
const timestamp = new Date().toISOString();
const logMessage = `🧱 Formbricks - ${timestamp} [${level.toUpperCase()}] - ${message}`;
if (level === "error") {
console.error(logMessage);
} else {
console.log(logMessage);
}
}
debug(message: string): void {
this.logger(message, "debug");
}
error(message: string): void {
this.logger(message, "error");
}
public resetInstance(): void {
Logger.instance = undefined;
}
}

View File

@@ -1,307 +0,0 @@
import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
import {
addCleanupEventListeners,
addEventListeners,
removeAllEventListeners,
} from "@/lib/common/event-listeners";
import { Logger } from "@/lib/common/logger";
import { AsyncStorage } from "@/lib/common/storage";
import { filterSurveys, isNowExpired, wrapThrowsAsync } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import { type TConfig, type TConfigInput, type TEnvironmentState, type TUserState } from "@/types/config";
import {
type MissingFieldError,
type MissingPersonError,
type NetworkError,
type NotSetupError,
type Result,
err,
okVoid,
} from "@/types/error";
let isSetup = false;
export const setIsSetup = (state: boolean): void => {
isSetup = state;
};
export const migrateUserStateAddContactId = async (): Promise<{ changed: boolean }> => {
const existingConfigString = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY);
if (existingConfigString) {
const existingConfig = JSON.parse(existingConfigString) as Partial<TConfig>;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
if (existingConfig.user?.data?.contactId) {
return { changed: false };
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
if (!existingConfig.user?.data?.contactId && existingConfig.user?.data?.userId) {
return { changed: true };
}
}
return { changed: false };
};
export const setup = async (
configInput: TConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
let appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
const { changed } = await migrateUserStateAddContactId();
if (changed) {
await appConfig.resetConfig();
appConfig = RNConfig.getInstance();
}
if (isSetup) {
logger.debug("Already set up, skipping setup.");
return okVoid();
}
let existingConfig: TConfig | undefined;
try {
existingConfig = appConfig.get();
logger.debug("Found existing configuration.");
} catch {
logger.debug("No existing configuration found.");
}
// formbricks is in error state, skip setup
if (existingConfig?.status.value === "error") {
logger.debug("Formbricks was set to an error state.");
const expiresAt = existingConfig.status.expiresAt;
if (expiresAt && isNowExpired(expiresAt)) {
logger.debug("Error state is not expired, skipping setup");
return okVoid();
}
logger.debug("Error state is expired. Continue with setup.");
}
logger.debug("Start setup");
if (!configInput.environmentId) {
logger.debug("No environmentId provided");
return err({
code: "missing_field",
field: "environmentId",
});
}
if (!configInput.appUrl) {
logger.debug("No appUrl provided");
return err({
code: "missing_field",
field: "appUrl",
});
}
if (
existingConfig?.environment &&
existingConfig.environmentId === configInput.environmentId &&
existingConfig.appUrl === configInput.appUrl
) {
logger.debug("Configuration fits setup parameters.");
let isEnvironmentStateExpired = false;
let isUserStateExpired = false;
if (isNowExpired(existingConfig.environment.expiresAt)) {
logger.debug("Environment state expired. Syncing.");
isEnvironmentStateExpired = true;
}
if (existingConfig.user.expiresAt && isNowExpired(existingConfig.user.expiresAt)) {
logger.debug("Person state expired. Syncing.");
isUserStateExpired = true;
}
try {
// fetch the environment state (if expired)
let environmentState: TEnvironmentState = existingConfig.environment;
let userState: TUserState = existingConfig.user;
if (isEnvironmentStateExpired) {
const environmentStateResponse = await fetchEnvironmentState({
appUrl: configInput.appUrl,
environmentId: configInput.environmentId,
});
if (environmentStateResponse.ok) {
environmentState = environmentStateResponse.data;
} else {
logger.error(
`Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}`
);
return err({
code: "network_error",
message: "Error fetching environment state",
status: 500,
url: new URL(`${configInput.appUrl}/api/v1/client/${configInput.environmentId}/environment`),
responseMessage: environmentStateResponse.error.message,
});
}
}
if (isUserStateExpired) {
// If the existing person state (expired) has a userId, we need to fetch the person state
// If the existing person state (expired) has no userId, we need to set the person state to the default
if (userState.data.userId) {
const updatesResponse = await sendUpdatesToBackend({
appUrl: configInput.appUrl,
environmentId: configInput.environmentId,
updates: {
userId: userState.data.userId,
},
});
if (updatesResponse.ok) {
userState = updatesResponse.data.state;
} else {
logger.error(
`Error updating user state: ${updatesResponse.error.code} - ${updatesResponse.error.responseMessage ?? ""}`
);
return err({
code: "network_error",
message: "Error updating user state",
status: 500,
url: new URL(
`${configInput.appUrl}/api/v1/client/${configInput.environmentId}/update/contacts/${userState.data.userId}`
),
responseMessage: "Unknown error",
});
}
} else {
userState = DEFAULT_USER_STATE_NO_USER_ID;
}
}
// filter the environment state wrt the person state
const filteredSurveys = filterSurveys(environmentState, userState);
// update the appConfig with the new filtered surveys and person state
appConfig.update({
...existingConfig,
environment: environmentState,
user: userState,
filteredSurveys,
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
} catch {
logger.debug("Error during sync. Please try again.");
}
} else {
logger.debug("No valid configuration found. Resetting config and creating new one.");
void appConfig.resetConfig();
logger.debug("Syncing.");
// During setup, if we don't have a valid config, we need to fetch the environment state
// but not the person state, we can set it to the default value.
// The person state will be fetched when the `setUserId` method is called.
try {
const environmentStateResponse = await fetchEnvironmentState({
appUrl: configInput.appUrl,
environmentId: configInput.environmentId,
});
if (!environmentStateResponse.ok) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error is ApiErrorResponse
throw environmentStateResponse.error;
}
const personState = DEFAULT_USER_STATE_NO_USER_ID;
const environmentState = environmentStateResponse.data;
const filteredSurveys = filterSurveys(environmentState, personState);
appConfig.update({
appUrl: configInput.appUrl,
environmentId: configInput.environmentId,
user: personState,
environment: environmentState,
filteredSurveys,
});
} catch (e) {
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
}
}
logger.debug("Adding event listeners");
addEventListeners();
addCleanupEventListeners();
setIsSetup(true);
logger.debug("Set up complete");
// check page url if set up after page load
return okVoid();
};
export const checkSetup = (): Result<void, NotSetupError> => {
const logger = Logger.getInstance();
logger.debug("Check if set up");
if (!isSetup) {
return err({
code: "not_setup",
message: "Formbricks is not set up. Call setup() first.",
});
}
return okVoid();
};
// eslint-disable-next-line @typescript-eslint/require-await -- disabled for now
export const tearDown = async (): Promise<void> => {
const logger = Logger.getInstance();
const appConfig = RNConfig.getInstance();
logger.debug("Setting user state to default");
// clear the user state and set it to the default value
appConfig.update({
...appConfig.get(),
user: DEFAULT_USER_STATE_NO_USER_ID,
});
setIsSetup(false);
removeAllEventListeners();
};
export const handleErrorOnFirstSetup = async (e: {
code: string;
responseMessage: string;
}): Promise<never> => {
const logger = Logger.getInstance();
if (e.code === "forbidden") {
logger.error(`Authorization error: ${e.responseMessage}`);
} else {
logger.error(`Error during first setup: ${e.code} - ${e.responseMessage}. Please try again later.`);
}
// put formbricks in error state (by creating a new config) and throw error
const initialErrorConfig: Partial<TConfig> = {
status: {
value: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
},
};
await wrapThrowsAsync(async () => {
await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(initialErrorConfig));
})();
throw new Error("Could not set up formbricks");
};

View File

@@ -1,9 +0,0 @@
import AsyncStorageModule from "@react-native-async-storage/async-storage";
const AsyncStorageWithDefault = AsyncStorageModule as typeof AsyncStorageModule & {
default?: typeof AsyncStorageModule;
};
const AsyncStorage = AsyncStorageWithDefault.default ?? AsyncStorageModule;
export { AsyncStorage };

View File

@@ -1,125 +0,0 @@
import type { TConfig } from "@/types/config";
// ids
export const mockEnvironmentId = "ggskhsue85p2xrxrc7x3qagg";
export const mockProjectId = "f5kptre0saxmltl7ram364qt";
export const mockLanguageId = "n4ts6u7wy5lbn4q3jovikqot";
export const mockSurveyId = "lz5m554yqh1i3moa3y230wei";
export const mockActionClassId = "wypzu5qw7adgy66vq8s77tso";
export const mockConfig: TConfig = {
environmentId: mockEnvironmentId,
appUrl: "https://myapp.example",
environment: {
expiresAt: "2999-12-31T23:59:59Z",
data: {
surveys: [
{
id: mockSurveyId,
name: "Onboarding Survey",
welcomeCard: null,
questions: [],
variables: [],
type: "app", // "link" or "app"
showLanguageSwitch: true,
endings: [],
autoClose: 5,
status: "inProgress", // whatever statuses you use
recontactDays: 7,
displayLimit: 1,
displayOption: "displayMultiple",
hiddenFields: [],
delay: 5, // e.g. 5s
projectOverwrites: {},
languages: [
{
// SurveyLanguage fields
surveyId: mockSurveyId,
default: true,
enabled: true,
languageId: mockLanguageId,
language: {
// Language fields
id: mockLanguageId,
code: "en",
alias: "en",
createdAt: "2025-01-01T10:00:00Z",
updatedAt: "2025-01-01T10:00:00Z",
projectId: mockProjectId,
},
},
],
triggers: [
{
actionClass: {
id: mockActionClassId,
key: "onboardingTrigger",
type: "code",
name: "Manual Trigger",
createdAt: "2025-01-01T10:00:00Z",
updatedAt: "2025-01-01T10:00:00Z",
environmentId: mockEnvironmentId,
description: "Manual Trigger",
noCodeConfig: {},
},
},
],
segment: undefined, // or mock your Segment if needed
displayPercentage: 100,
styling: {
// TSurveyStyling
overwriteThemeStyling: false,
brandColor: { light: "#2B6CB0" },
},
},
],
actionClasses: [
{
id: mockActionClassId,
key: "onboardingTrigger",
type: "code",
name: "Manual Trigger",
noCodeConfig: {},
},
],
project: {
id: mockProjectId,
recontactDays: 14,
clickOutsideClose: true,
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: {
// TProjectStyling
allowStyleOverwrite: true,
brandColor: { light: "#319795" },
},
},
},
},
user: {
expiresAt: null,
data: {
userId: "user_abc",
segments: ["beta-testers"],
displays: [
{
surveyId: mockSurveyId,
createdAt: "2025-01-01T10:00:00Z",
},
],
responses: [mockSurveyId],
lastDisplayAt: "2025-01-02T15:00:00Z",
language: "en",
},
},
filteredSurveys: [], // fill if you'd like to pre-filter any surveys
attributes: {
plan: "premium",
region: "US",
},
status: {
value: "success",
expiresAt: null,
},
} as unknown as TConfig;

View File

@@ -1,318 +0,0 @@
// api.test.ts
import { ApiClient, makeRequest } from "@/lib/common/api";
import type { TEnvironmentState } from "@/types/config";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("api.ts", () => {
beforeEach(() => {
vi.resetAllMocks();
});
// ---------------------------------------------------------------------------------
// makeRequest
// ---------------------------------------------------------------------------------
describe("makeRequest()", () => {
test("successful GET request", async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ data: { test: "data" } }),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/test", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ test: "data" });
}
});
test("successful POST request with data", async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ data: { test: "data" } }),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "POST", {
input: "data",
});
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ input: "data" }),
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ test: "data" });
}
});
test("handles network error", async () => {
const mockError = {
code: "network_error",
message: "Something went wrong",
status: 500,
};
mockFetch.mockRejectedValue(mockError);
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(mockError.code);
}
});
test("handles non-OK response", async () => {
const mockResponse = {
ok: false,
status: 404,
json: vi.fn().mockResolvedValue({
code: "not_found",
message: "Resource not found",
}),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: "network_error",
status: 404,
message: "Resource not found",
url: expect.any(URL) as URL,
});
}
});
test("handles forbidden response", async () => {
const mockResponse = {
ok: false,
status: 403,
json: vi.fn().mockResolvedValue({
code: "forbidden",
message: "Access forbidden",
}),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: "forbidden",
status: 403,
message: "Access forbidden",
url: expect.any(URL) as URL,
});
}
});
test("handles error response with details", async () => {
const mockResponse = {
ok: false,
status: 400,
json: vi.fn().mockResolvedValue({
code: "bad_request",
message: "Invalid input",
details: { field: "email", message: "Invalid email format" },
}),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: "network_error",
status: 400,
message: "Invalid input",
url: expect.any(URL) as URL,
details: { field: "email", message: "Invalid email format" },
});
}
});
test("uses debug mode when specified", async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ data: { test: "data" } }),
};
mockFetch.mockResolvedValue(mockResponse);
await makeRequest<{ test: string }>("https://example.com", "/api/test", "GET", undefined, true);
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/test", {
method: "GET",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
});
});
});
// ---------------------------------------------------------------------------------
// ApiClient
// ---------------------------------------------------------------------------------
describe("ApiClient", () => {
let apiClient: ApiClient;
beforeEach(() => {
apiClient = new ApiClient({
appUrl: "https://example.com",
environmentId: "env123",
isDebug: false,
});
});
test("creates or updates user successfully", async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
data: {
state: {
expiresAt: new Date("2023-01-01"),
data: {
userId: "user123",
contactId: "contact123",
segments: ["segment1"],
displays: [{ surveyId: "survey1", createdAt: new Date() }],
responses: ["response1"],
lastDisplayAt: new Date(),
},
},
messages: ["User updated successfully"],
},
}),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await apiClient.createOrUpdateUser({
userId: "user123",
attributes: { name: "John", age: "30" },
});
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/v2/client/env123/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: "user123",
attributes: { name: "John", age: "30" },
}),
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.state.data.userId).toBe("user123");
expect(result.data.messages).toEqual(["User updated successfully"]);
}
});
test("creates or updates user with error", async () => {
const mockResponse = {
ok: false,
status: 400,
json: vi.fn().mockResolvedValue({
code: "bad_request",
message: "Invalid user data",
}),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await apiClient.createOrUpdateUser({
userId: "user123",
attributes: { name: "John", age: "30" },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.message).toBe("Invalid user data");
}
});
test("gets environment state successfully", async () => {
const mockEnvironmentState: TEnvironmentState = {
expiresAt: new Date("2023-01-01"),
data: {
surveys: [],
actionClasses: [],
project: {
id: "project123",
recontactDays: 30,
clickOutsideClose: true,
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: {
allowStyleOverwrite: true,
},
},
},
};
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
data: mockEnvironmentState,
}),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await apiClient.getEnvironmentState();
expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/v1/client/env123/environment", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockEnvironmentState);
}
});
test("gets environment state with error", async () => {
const mockResponse = {
ok: false,
status: 404,
json: vi.fn().mockResolvedValue({
code: "not_found",
message: "Environment not found",
}),
};
mockFetch.mockResolvedValue(mockResponse);
const result = await apiClient.getEnvironmentState();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.message).toBe("Environment not found");
}
});
});
});

View File

@@ -1,165 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { CommandQueue } from "@/lib/common/command-queue";
import { checkSetup } from "@/lib/common/setup";
import { type Result } from "@/types/error";
// Mock the setup module so we can control checkSetup()
vi.mock("@/lib/common/setup", () => ({
checkSetup: vi.fn(),
}));
describe("CommandQueue", () => {
let queue: CommandQueue;
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
// Create a fresh CommandQueue instance
queue = new CommandQueue();
});
test("executes commands in FIFO order", async () => {
const executionOrder: string[] = [];
// Mock commands with proper Result returns
const cmdA = vi.fn(async (): Promise<Result<void, unknown>> => {
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push("A");
resolve({ ok: true, data: undefined });
}, 10);
});
});
const cmdB = vi.fn(async (): Promise<Result<void, unknown>> => {
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push("B");
resolve({ ok: true, data: undefined });
}, 10);
});
});
const cmdC = vi.fn(async (): Promise<Result<void, unknown>> => {
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push("C");
resolve({ ok: true, data: undefined });
}, 10);
});
});
// We'll assume checkSetup always ok for this test
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
// Enqueue commands
queue.add(cmdA, true);
queue.add(cmdB, true);
queue.add(cmdC, true);
// Wait for them to finish
await queue.wait();
expect(executionOrder).toEqual(["A", "B", "C"]);
});
test("skips execution if checkSetup() fails", async () => {
const cmd = vi.fn(async (): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 10);
});
});
// Force checkSetup to fail
vi.mocked(checkSetup).mockReturnValue({
ok: false,
error: {
code: "not_setup",
message: "Not setup",
},
});
queue.add(cmd, true);
await queue.wait();
// Command should never have been called
expect(cmd).not.toHaveBeenCalled();
});
test("executes command if checkSetup is false (no check)", async () => {
const cmd = vi.fn(async (): Promise<Result<void, unknown>> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: true, data: undefined });
}, 10);
});
});
// checkSetup is irrelevant in this scenario, but let's mock it anyway
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
// Here we pass 'false' for the second argument, so no check is performed
queue.add(cmd, false);
await queue.wait();
expect(cmd).toHaveBeenCalledTimes(1);
});
test("logs errors if a command throws or returns error", async () => {
// Spy on console.error to see if it's called
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
return {
ok: true,
data: undefined,
};
});
// Force checkSetup to succeed
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
// Mock command that fails
const failingCmd = vi.fn(async () => {
await new Promise((resolve) => {
setTimeout(() => {
resolve("some error");
}, 10);
});
throw new Error("some error");
});
queue.add(failingCmd, true);
await queue.wait();
expect(consoleErrorSpy).toHaveBeenCalledWith("🧱 Formbricks - Global error: ", expect.any(Error));
consoleErrorSpy.mockRestore();
});
test("resolves wait() after all commands complete", async () => {
const cmd1 = vi.fn(async (): Promise<Result<void, unknown>> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: true, data: undefined });
}, 10);
});
});
const cmd2 = vi.fn(async (): Promise<Result<void, unknown>> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: true, data: undefined });
}, 10);
});
});
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
queue.add(cmd1, true);
queue.add(cmd2, true);
await queue.wait();
// By the time `await queue.wait()` resolves, both commands should be done
expect(cmd1).toHaveBeenCalled();
expect(cmd2).toHaveBeenCalled();
});
});

View File

@@ -1,127 +0,0 @@
// config.test.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { mockConfig } from "./__mocks__/config.mock";
import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
import type { TConfig, TConfigUpdateInput } from "@/types/config";
// Define mocks outside of any describe block
describe("RNConfig", () => {
let configInstance: RNConfig;
beforeEach(async () => {
// Clear mocks between tests
vi.clearAllMocks();
// get the config instance
configInstance = RNConfig.getInstance();
// reset the config
await configInstance.resetConfig();
// get the config instance again
configInstance = RNConfig.getInstance();
});
afterEach(() => {
// In case we want to restore them after all tests
vi.restoreAllMocks();
});
test("getInstance() returns a singleton", () => {
const secondInstance = RNConfig.getInstance();
expect(configInstance).toBe(secondInstance);
});
test("get() throws if config is null", () => {
// constructor didn't load anything successfully
// so config is still null
expect(() => configInstance.get()).toThrow("config is null, maybe the init function was not called?");
});
test("loadFromStorage() returns ok if valid config is found", async () => {
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
const result = await configInstance.loadFromStorage();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockConfig);
}
});
test("loadFromStorage() returns err if config is expired", async () => {
const expiredConfig = {
...mockConfig,
environment: {
...mockConfig.environment,
expiresAt: new Date("2000-01-01T00:00:00Z"),
},
};
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(expiredConfig));
const result = await configInstance.loadFromStorage();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("Config in local storage has expired");
}
});
test("loadFromStorage() returns err if no or invalid config in storage", async () => {
// Simulate no data
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(null);
const result = await configInstance.loadFromStorage();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("No or invalid config in local storage");
}
});
test("update() merges new config, calls saveToStorage()", async () => {
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
// Wait for the constructor's async load
await new Promise(setImmediate);
// Now we call update()
const newStatus = { value: "error", expiresAt: "2100-01-01T00:00:00Z" } as unknown as TConfig["status"];
configInstance.update({ ...mockConfig, status: newStatus } as unknown as TConfigUpdateInput);
// The update call should eventually call setItem on AsyncStorage
expect(AsyncStorage.setItem).toHaveBeenCalled();
// Lets check if we can read the updated config:
const updatedConfig = configInstance.get();
expect(updatedConfig.status.value).toBe("error");
expect(updatedConfig.status.expiresAt).toBe("2100-01-01T00:00:00Z");
});
test("saveToStorage() is invoked internally on update()", async () => {
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
await new Promise(setImmediate);
configInstance.update({ status: { value: "success", expiresAt: null } } as unknown as TConfigUpdateInput);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
RN_ASYNC_STORAGE_KEY,
expect.any(String) // the JSON string
);
});
test("resetConfig() clears config and AsyncStorage", async () => {
vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
await new Promise(setImmediate);
// Now reset
const result = await configInstance.resetConfig();
expect(result.ok).toBe(true);
// config is now null
expect(() => configInstance.get()).toThrow("config is null");
// removeItem should be called
expect(AsyncStorage.removeItem).toHaveBeenCalledWith(RN_ASYNC_STORAGE_KEY);
});
});

View File

@@ -1,186 +0,0 @@
// file-upload.test.ts
import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageAPI } from "@/lib/common/file-upload";
import type { TUploadFileConfig } from "@/types/storage";
// A global fetch mock so we can capture fetch calls.
// Alternatively, use `vi.stubGlobal("fetch", ...)`.
const fetchMock = vi.fn();
global.fetch = fetchMock;
const mockEnvironmentId = "dv46cywjt1fxkkempq7vwued";
describe("StorageAPI", () => {
const APP_URL = "https://myapp.example";
const ENV_ID = mockEnvironmentId;
let storage: StorageAPI;
beforeEach(() => {
vi.clearAllMocks();
storage = new StorageAPI(APP_URL, ENV_ID);
});
test("throws an error if file object is invalid", async () => {
// File missing "name", "type", or "base64"
await expect(storage.uploadFile({ type: "", name: "", base64: "" }, {})).rejects.toThrow(
"Invalid file object"
);
});
test("throws if first fetch (storage route) returns non-OK", async () => {
// We provide a valid file object
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
// First fetch returns not ok
fetchMock.mockResolvedValueOnce({
ok: false,
status: 400,
} as Response);
await expect(storage.uploadFile(file)).rejects.toThrow("Upload failed with status: 400");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
`${APP_URL}/api/v1/client/${ENV_ID}/storage`,
expect.objectContaining({
method: "POST",
})
);
});
test("throws if second fetch returns non-OK (local storage w/ signingData)", async () => {
// Suppose the first fetch is OK and returns JSON with signingData
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => {
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
return {
data: {
signedUrl: "https://myapp.example/uploadLocal",
fileUrl: "https://myapp.example/files/test.png",
signingData: { signature: "xxx", timestamp: 1234, uuid: "abc" },
presignedFields: null,
updatedFileName: "test.png",
},
};
},
} as Response)
// second fetch fails
.mockResolvedValueOnce({
ok: false,
json: async () => {
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
return { message: "File size exceeded your plan limit" };
},
} as Response);
await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeded your plan limit");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test("throws if second fetch returns non-OK (S3) containing 'EntityTooLarge'", async () => {
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
// First fetch response includes presignedFields => indicates S3 scenario
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => {
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
return {
data: {
signedUrl: "https://some-s3-bucket/presigned",
fileUrl: "https://some-s3-bucket/test.png",
signingData: null, // means not local
presignedFields: {
key: "some-key",
policy: "base64policy",
},
updatedFileName: "test.png",
},
};
},
} as Response)
// second fetch fails with "EntityTooLarge"
.mockResolvedValueOnce({
ok: false,
text: async () => {
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
return "Some error with EntityTooLarge text in it";
},
} as Response);
await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeds the size limit for your plan");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test("successful upload returns fileUrl", async () => {
const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
const mockFileUrl = "https://myapp.example/files/test.png";
// First fetch => OK, returns JSON with 'signedUrl', 'fileUrl', etc.
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => {
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
return {
data: {
signedUrl: "https://myapp.example/uploadLocal",
fileUrl: mockFileUrl,
signingData: {
signature: "xxx",
timestamp: 1234,
uuid: "abc",
},
presignedFields: null,
updatedFileName: "test.png",
},
};
},
} as Response)
// second fetch => also OK
.mockResolvedValueOnce({
ok: true,
} as Response);
const url = await storage.uploadFile(file, {
allowedFileExtensions: [".png", ".jpg"],
surveyId: "survey_123",
} as TUploadFileConfig);
expect(url).toBe(mockFileUrl);
expect(fetchMock).toHaveBeenCalledTimes(2);
// We can also check the first fetch request body
const firstCall = fetchMock.mock.calls[0];
expect(firstCall[0]).toBe(`${APP_URL}/api/v1/client/${ENV_ID}/storage`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- we know it's a string
const bodyPayload = JSON.parse(firstCall[1].body as string);
expect(bodyPayload).toMatchObject({
fileName: "test.png",
fileType: "image/png",
allowedFileExtensions: [".png", ".jpg"],
surveyId: "survey_123",
});
});
});

View File

@@ -1,82 +0,0 @@
// logger.test.ts
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Logger } from "@/lib/common/logger";
// adjust import path as needed
describe("Logger", () => {
let logger: Logger;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logger = Logger.getInstance();
// Reset any existing singleton
logger.resetInstance();
logger = Logger.getInstance();
// Mock console so we don't actually log in test output
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {
return {
ok: true,
data: undefined,
};
});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
return {
ok: true,
data: undefined,
};
});
});
afterEach(() => {
vi.restoreAllMocks();
});
test("getInstance() returns a singleton", () => {
const anotherLogger = Logger.getInstance();
expect(logger).toBe(anotherLogger);
});
test("default logLevel is 'error', so debug messages shouldn't appear", () => {
logger.debug("This is a debug log");
logger.error("This is an error log");
// debug should NOT be logged by default
expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining("This is a debug log"));
// error should be logged
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR] - This is an error log"));
});
test("configure to logLevel=debug => debug messages appear", () => {
logger.configure({ logLevel: "debug" });
logger.debug("Debug log after config");
logger.error("Error log after config");
// debug should now appear
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringMatching(/🧱 Formbricks.*\[DEBUG\].*Debug log after config/)
);
// error should appear as well
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/🧱 Formbricks.*\[ERROR\].*Error log after config/)
);
});
test("logs have correct format including timestamp prefix", () => {
logger.configure({ logLevel: "debug" });
logger.debug("Some message");
// Check that the log includes 🧱 Formbricks, timestamp, [DEBUG], and the message
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringMatching(
/^🧱 Formbricks - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z \[DEBUG\] - Some message$/
)
);
});
});

View File

@@ -1,359 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
import {
addCleanupEventListeners,
addEventListeners,
removeAllEventListeners,
} from "@/lib/common/event-listeners";
import { Logger } from "@/lib/common/logger";
import { checkSetup, handleErrorOnFirstSetup, setIsSetup, setup, tearDown } from "@/lib/common/setup";
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
// 1) Mock AsyncStorage
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
setItem: vi.fn(),
getItem: vi.fn(),
removeItem: vi.fn(),
},
}));
// 2) Mock RNConfig
vi.mock("@/lib/common/config", () => ({
RN_ASYNC_STORAGE_KEY: "formbricks-react-native",
RNConfig: {
getInstance: vi.fn(() => ({
get: vi.fn(),
update: vi.fn(),
resetConfig: vi.fn(),
})),
},
}));
// 3) Mock logger
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
// 4) Mock event-listeners
vi.mock("@/lib/common/event-listeners", () => ({
addEventListeners: vi.fn(),
addCleanupEventListeners: vi.fn(),
removeAllEventListeners: vi.fn(),
}));
// 5) Mock fetchEnvironmentState
vi.mock("@/lib/environment/state", () => ({
fetchEnvironmentState: vi.fn(),
}));
// 6) Mock filterSurveys
vi.mock("@/lib/common/utils", async (importOriginal) => {
return {
...(await importOriginal<typeof import("@/lib/common/utils")>()),
filterSurveys: vi.fn(),
isNowExpired: vi.fn(),
};
});
// 7) Mock user/update
vi.mock("@/lib/user/update", () => ({
sendUpdatesToBackend: vi.fn(),
}));
describe("setup.ts", () => {
let getInstanceConfigMock: MockInstance<() => RNConfig>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
const mockLogger = {
debug: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
// By default, set isSetup to false so we can test setup logic from scratch
setIsSetup(false);
getInstanceConfigMock = vi.spyOn(RNConfig, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("setup()", () => {
test("returns ok if already setup", async () => {
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
setIsSetup(true);
const result = await setup({ environmentId: "env_id", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
expect(mockLogger.debug).toHaveBeenCalledWith("Already set up, skipping setup.");
});
test("fails if no environmentId is provided", async () => {
const result = await setup({ environmentId: "", appUrl: "https://my.url" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("missing_field");
}
});
test("fails if no appUrl is provided", async () => {
const result = await setup({ environmentId: "env_123", appUrl: "" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("missing_field");
}
});
test("skips setup if existing config is in error state and not expired", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: {},
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
}),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
(isNowExpired as unknown as Mock).mockReturnValue(true);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
expect(mockLogger.debug).toHaveBeenCalledWith("Formbricks was set to an error state.");
expect(mockLogger.debug).toHaveBeenCalledWith("Error state is not expired, skipping setup");
});
test("proceeds if error state is expired", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: {},
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() - 10000) }, // expired
}),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
expect(mockLogger.debug).toHaveBeenCalledWith("Formbricks was set to an error state.");
expect(mockLogger.debug).toHaveBeenCalledWith("Error state is expired. Continue with setup.");
});
test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: { expiresAt: new Date(Date.now() - 5000) }, // environment expired
user: {
data: { userId: "user_abc" },
expiresAt: new Date(Date.now() - 5000), // also expired
},
status: { value: "success", expiresAt: null },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
(isNowExpired as unknown as Mock).mockReturnValue(true);
// Mock environment fetch success
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
ok: true,
data: { data: { surveys: [] }, expiresAt: new Date(Date.now() + 60_000) },
});
// Mock sendUpdatesToBackend success
(sendUpdatesToBackend as unknown as Mock).mockResolvedValueOnce({
ok: true,
data: {
state: {
expiresAt: new Date(),
data: { userId: "user_abc", segments: [] },
},
},
});
(filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "S1" }, { name: "S2" }]);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
// environmentState was fetched
expect(fetchEnvironmentState).toHaveBeenCalled();
// user state was updated
expect(sendUpdatesToBackend).toHaveBeenCalled();
// filterSurveys called
expect(filterSurveys).toHaveBeenCalled();
// config updated
expect(mockConfig.update).toHaveBeenCalledWith(
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- required for testing this object
user: expect.objectContaining({
data: { userId: "user_abc", segments: [] },
}),
filteredSurveys: [{ name: "S1" }, { name: "S2" }],
})
);
});
test("resets config if no valid config found, fetches environment, sets default user", async () => {
const mockConfig = {
get: () => {
throw new Error("no config found");
},
resetConfig: vi.fn(),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
ok: true,
data: {
data: {
surveys: [{ name: "SurveyA" }],
expiresAt: new Date(Date.now() + 60000),
},
},
});
(filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "SurveyA" }]);
const result = await setup({ environmentId: "envX", appUrl: "https://urlX" });
expect(result.ok).toBe(true);
expect(mockLogger.debug).toHaveBeenCalledWith("No existing configuration found.");
expect(mockLogger.debug).toHaveBeenCalledWith(
"No valid configuration found. Resetting config and creating new one."
);
expect(mockConfig.resetConfig).toHaveBeenCalled();
expect(fetchEnvironmentState).toHaveBeenCalled();
expect(mockConfig.update).toHaveBeenCalledWith({
appUrl: "https://urlX",
environmentId: "envX",
user: DEFAULT_USER_STATE_NO_USER_ID,
environment: {
data: {
surveys: [{ name: "SurveyA" }],
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- required for testing this object
expiresAt: expect.any(Date),
},
},
filteredSurveys: [{ name: "SurveyA" }],
});
});
test("calls handleErrorOnFirstSetup if environment fetch fails initially", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue(undefined),
update: vi.fn(),
resetConfig: vi.fn(),
};
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
ok: false,
error: { code: "forbidden", responseMessage: "No access" },
});
await expect(setup({ environmentId: "envX", appUrl: "https://urlX" })).rejects.toThrow(
"Could not set up formbricks"
);
});
test("adds event listeners and sets isSetup", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_abc",
appUrl: "https://test.app",
environment: {},
user: { data: {}, expiresAt: null },
status: { value: "success", expiresAt: null },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
const result = await setup({ environmentId: "env_abc", appUrl: "https://test.app" });
expect(result.ok).toBe(true);
expect(addEventListeners).toHaveBeenCalled();
expect(addCleanupEventListeners).toHaveBeenCalled();
});
});
describe("checkSetup()", () => {
test("returns err if not setup", () => {
const res = checkSetup();
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.code).toBe("not_setup");
}
});
test("returns ok if setup", () => {
setIsSetup(true);
const res = checkSetup();
expect(res.ok).toBe(true);
});
});
describe("tearDown()", () => {
test("resets user state to default and removes event listeners", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: { data: { userId: "XYZ" } },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
await tearDown();
expect(mockConfig.update).toHaveBeenCalledWith(
expect.objectContaining({
user: DEFAULT_USER_STATE_NO_USER_ID,
})
);
expect(removeAllEventListeners).toHaveBeenCalled();
});
});
describe("handleErrorOnFirstSetup()", () => {
test("stores error state in AsyncStorage, throws error", async () => {
// We import the function directly
const errorObj = { code: "forbidden", responseMessage: "No access" };
await expect(async () => {
await handleErrorOnFirstSetup(errorObj);
}).rejects.toThrow("Could not set up formbricks");
// AsyncStorage setItem should be called with the error config
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
RN_ASYNC_STORAGE_KEY,
expect.stringContaining('"value":"error"')
);
});
});
});

View File

@@ -1,395 +0,0 @@
// utils.test.ts
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mockProjectId, mockSurveyId } from "@/lib/common/tests/__mocks__/config.mock";
import {
diffInDays,
filterSurveys,
getDefaultLanguageCode,
getLanguageCode,
getStyling,
shouldDisplayBasedOnPercentage,
wrapThrowsAsync,
} from "@/lib/common/utils";
import type {
TEnvironmentState,
TEnvironmentStateProject,
TEnvironmentStateSurvey,
TSurveyStyling,
TUserState,
} from "@/types/config";
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
const mockSurveyId2 = "qo9rwjmms42hoy3k85fp8vgu";
const mockSegmentId1 = "p6yrnz3s2tvoe5r0l28unq7k";
const mockSegmentId2 = "wz43zrxeddhb1uo9cicustar";
describe("utils.ts", () => {
// ---------------------------------------------------------------------------------
// diffInDays
// ---------------------------------------------------------------------------------
describe("diffInDays()", () => {
test("calculates correct day difference", () => {
const date1 = new Date("2023-01-01");
const date2 = new Date("2023-01-05");
expect(diffInDays(date1, date2)).toBe(4); // four days apart
});
test("handles negative differences (abs)", () => {
const date1 = new Date("2023-01-10");
const date2 = new Date("2023-01-05");
expect(diffInDays(date1, date2)).toBe(5);
});
test("0 if same day", () => {
const date = new Date("2023-01-01");
expect(diffInDays(date, date)).toBe(0);
});
});
// ---------------------------------------------------------------------------------
// wrapThrowsAsync
// ---------------------------------------------------------------------------------
describe("wrapThrowsAsync()", () => {
test("returns ok on success", async () => {
const fn = vi.fn(async (x: number) => {
await new Promise((r) => {
setTimeout(r, 10);
});
return x * 2;
});
const wrapped = wrapThrowsAsync(fn);
const result = await wrapped(5);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(10);
}
});
test("returns err on error", async () => {
const fn = vi.fn(async () => {
await new Promise((r) => {
setTimeout(r, 10);
});
throw new Error("Something broke");
});
const wrapped = wrapThrowsAsync(fn);
const result = await wrapped();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("Something broke");
}
});
});
// ---------------------------------------------------------------------------------
// filterSurveys
// ---------------------------------------------------------------------------------
describe("filterSurveys()", () => {
// We'll create a minimal environment state
let environment: TEnvironmentState;
let user: TUserState;
const baseSurvey: Partial<TEnvironmentStateSurvey> = {
id: mockSurveyId,
displayOption: "displayOnce",
displayLimit: 1,
recontactDays: null,
languages: [],
};
beforeEach(() => {
environment = {
expiresAt: new Date(),
data: {
project: {
id: mockProjectId,
recontactDays: 7, // fallback if survey doesn't have it
clickOutsideClose: false,
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
} as TEnvironmentStateProject,
surveys: [],
actionClasses: [],
},
};
user = {
expiresAt: null,
data: {
userId: null,
contactId: null,
segments: [],
displays: [],
responses: [],
lastDisplayAt: null,
},
};
});
test("returns no surveys if user has no segments and userId is set", () => {
user.data.userId = "user_abc";
// environment has a single survey
environment.data.surveys = [
{ ...baseSurvey, id: mockSurveyId1, segment: { id: mockSegmentId1 } } as TEnvironmentStateSurvey,
];
const result = filterSurveys(environment, user);
expect(result).toEqual([]); // no segments => none pass
});
test("returns surveys if user has no userId but displayOnce and no displays yet", () => {
// userId is null => it won't segment filter
environment.data.surveys = [
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
];
const result = filterSurveys(environment, user);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(mockSurveyId1);
});
test("skips surveys that already displayed if displayOnce is used", () => {
environment.data.surveys = [
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
];
user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }];
const result = filterSurveys(environment, user);
expect(result).toEqual([]);
});
test("skips surveys if user responded to them and displayOption=displayMultiple", () => {
environment.data.surveys = [
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayMultiple" } as TEnvironmentStateSurvey,
];
user.data.responses = [mockSurveyId1];
const result = filterSurveys(environment, user);
expect(result).toEqual([]);
});
test("handles displaySome logic with displayLimit", () => {
environment.data.surveys = [
{
...baseSurvey,
id: mockSurveyId1,
displayOption: "displaySome",
displayLimit: 2,
} as TEnvironmentStateSurvey,
];
// user has 1 display of s1
user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }];
// No responses => so it's still allowed
const result = filterSurveys(environment, user);
expect(result).toHaveLength(1);
});
test("filters out surveys if recontactDays not met", () => {
// Suppose survey uses project fallback (7 days)
environment.data.surveys = [
{ ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
];
// user last displayAt is only 3 days ago
user.data.lastDisplayAt = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const result = filterSurveys(environment, user);
expect(result).toHaveLength(0);
});
test("passes surveys if enough days have passed since lastDisplayAt", () => {
// user last displayAt is 8 days ago
user.data.lastDisplayAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
environment.data.surveys = [
{
...baseSurvey,
id: mockSurveyId1,
displayOption: "respondMultiple",
recontactDays: null,
} as TEnvironmentStateSurvey,
];
const result = filterSurveys(environment, user);
expect(result).toHaveLength(1);
});
test("filters by segment if userId is set and user has segments", () => {
user.data.userId = "user_abc";
user.data.segments = [mockSegmentId1];
environment.data.surveys = [
{
...baseSurvey,
id: mockSurveyId1,
segment: { id: mockSegmentId1 },
displayOption: "respondMultiple",
} as TEnvironmentStateSurvey,
{
...baseSurvey,
id: mockSurveyId2,
segment: { id: mockSegmentId2 },
displayOption: "respondMultiple",
} as TEnvironmentStateSurvey,
];
const result = filterSurveys(environment, user);
// only the one that matches user's segment
expect(result).toHaveLength(1);
expect(result[0].id).toBe(mockSurveyId1);
});
});
// ---------------------------------------------------------------------------------
// getStyling
// ---------------------------------------------------------------------------------
describe("getStyling()", () => {
test("returns project styling if allowStyleOverwrite=false", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: false, brandColor: { light: "#fff" } },
} as TEnvironmentStateProject;
const survey = {
styling: {
overwriteThemeStyling: true,
brandColor: { light: "#000" },
} as TSurveyStyling,
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
// should get project styling
expect(result).toEqual(project.styling);
});
test("returns project styling if allowStyleOverwrite=true but survey overwriteThemeStyling=false", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
} as TEnvironmentStateProject;
const survey = {
styling: {
overwriteThemeStyling: false,
brandColor: { light: "#000" },
} as TSurveyStyling,
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
// should get project styling still
expect(result).toEqual(project.styling);
});
test("returns survey styling if allowStyleOverwrite=true and survey overwriteThemeStyling=true", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
} as TEnvironmentStateProject;
const survey = {
styling: {
overwriteThemeStyling: true,
brandColor: { light: "#000" },
} as TSurveyStyling,
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
expect(result).toEqual(survey.styling);
});
});
// ---------------------------------------------------------------------------------
// getDefaultLanguageCode
// ---------------------------------------------------------------------------------
describe("getDefaultLanguageCode()", () => {
test("returns code of the language if it is flagged default", () => {
const survey = {
languages: [
{
language: { code: "en" },
default: false,
enabled: true,
},
{
language: { code: "fr" },
default: true,
enabled: true,
},
],
} as unknown as TEnvironmentStateSurvey;
expect(getDefaultLanguageCode(survey)).toBe("fr");
});
test("returns undefined if no default language found", () => {
const survey = {
languages: [
{ language: { code: "en" }, default: false, enabled: true },
{ language: { code: "fr" }, default: false, enabled: true },
],
} as unknown as TEnvironmentStateSurvey;
expect(getDefaultLanguageCode(survey)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------------
// getLanguageCode
// ---------------------------------------------------------------------------------
describe("getLanguageCode()", () => {
test("returns 'default' if no language param is passed", () => {
const survey = {
languages: [{ language: { code: "en" }, default: true, enabled: true }],
} as unknown as TEnvironmentStateSurvey;
const code = getLanguageCode(survey, undefined);
expect(code).toBe("default");
});
test("returns 'default' if the chosen language is the default one", () => {
const survey = {
languages: [
{ language: { code: "en" }, default: true, enabled: true },
{ language: { code: "fr" }, default: false, enabled: true },
],
} as unknown as TEnvironmentStateSurvey;
const code = getLanguageCode(survey, "en");
expect(code).toBe("default");
});
test("returns undefined if language not found or disabled", () => {
const survey = {
languages: [
{ language: { code: "en" }, default: true, enabled: true },
{ language: { code: "fr" }, default: false, enabled: false },
],
} as unknown as TEnvironmentStateSurvey;
const code = getLanguageCode(survey, "fr");
expect(code).toBeUndefined();
});
test("returns the language code if found and enabled", () => {
const survey = {
languages: [
{ language: { code: "en", alias: "English" }, default: true, enabled: true },
{ language: { code: "fr", alias: "fr-FR" }, default: false, enabled: true },
],
} as unknown as TEnvironmentStateSurvey;
expect(getLanguageCode(survey, "fr")).toBe("fr");
expect(getLanguageCode(survey, "fr-FR")).toBe("fr");
});
});
// ---------------------------------------------------------------------------------
// shouldDisplayBasedOnPercentage
// ---------------------------------------------------------------------------------
describe("shouldDisplayBasedOnPercentage()", () => {
test("returns true if random number <= displayPercentage", () => {
// We'll mock Math.random to return something
const mockedRandom = vi.spyOn(Math, "random").mockReturnValue(0.2); // 0.2 => 20%
// displayPercentage = 30 => 30% => we should display
expect(shouldDisplayBasedOnPercentage(30)).toBe(true);
mockedRandom.mockReturnValue(0.5); // 50%
expect(shouldDisplayBasedOnPercentage(30)).toBe(false);
// restore
mockedRandom.mockRestore();
});
});
});

View File

@@ -1,170 +0,0 @@
import type {
TEnvironmentState,
TEnvironmentStateProject,
TEnvironmentStateSurvey,
TProjectStyling,
TSurveyStyling,
TUserState,
} from "@/types/config";
import type { Result } from "@/types/error";
// Helper function to calculate difference in days between two dates
export const diffInDays = (date1: Date, date2: Date): number => {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const wrapThrowsAsync =
<T, A extends unknown[]>(fn: (...args: A) => Promise<T>) =>
async (...args: A): Promise<Result<T>> => {
try {
return {
ok: true,
data: await fn(...args),
};
} catch (error) {
return {
ok: false,
error: error as Error,
};
}
};
/**
* Filters surveys based on the displayOption, recontactDays, and segments
* @param environmentSate - The environment state
* @param userState - The user state
* @returns The filtered surveys
*/
// takes the environment and user state and returns the filtered surveys
export const filterSurveys = (
environmentState: TEnvironmentState,
userState: TUserState
): TEnvironmentStateSurvey[] => {
const { project, surveys } = environmentState.data;
const { displays, responses, lastDisplayAt, segments, userId } = userState.data;
// Function to filter surveys based on displayOption criteria
let filteredSurveys = surveys.filter((survey: TEnvironmentStateSurvey) => {
switch (survey.displayOption) {
case "respondMultiple":
return true;
case "displayOnce":
return displays.filter((display) => display.surveyId === survey.id).length === 0;
case "displayMultiple":
return responses.filter((surveyId) => surveyId === survey.id).length === 0;
case "displaySome":
if (survey.displayLimit === null) {
return true;
}
// Check if survey response exists, if so, stop here
if (responses.filter((surveyId) => surveyId === survey.id).length) {
return false;
}
// Otherwise, check if displays length is less than displayLimit
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
default:
throw Error("Invalid displayOption");
}
});
// filter surveys that meet the recontactDays criteria
filteredSurveys = filteredSurveys.filter((survey) => {
// if no survey was displayed yet, show the survey
if (!lastDisplayAt) {
return true;
}
// if survey has recontactDays, check if the last display was more than recontactDays ago
// The previous approach checked the last display for each survey which is why we still have a surveyId in the displays array.
// TODO: Remove the surveyId from the displays array
if (survey.recontactDays !== null) {
return diffInDays(new Date(), new Date(lastDisplayAt)) >= survey.recontactDays;
}
// use recontactDays of the project if survey does not have recontactDays
if (project.recontactDays) {
return diffInDays(new Date(), new Date(lastDisplayAt)) >= project.recontactDays;
}
// if no recontactDays is set, show the survey
return true;
});
if (!userId) {
return filteredSurveys;
}
if (!segments.length) {
return [];
}
// filter surveys based on segments
return filteredSurveys.filter((survey) => {
return survey.segment?.id && segments.includes(survey.segment.id);
});
};
export const getStyling = (
project: TEnvironmentStateProject,
survey: TEnvironmentStateSurvey
): TProjectStyling | TSurveyStyling => {
// allow style overwrite is enabled from the project
if (project.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return project.styling;
}
// survey style overwrite is enabled
return survey.styling;
}
// allow style overwrite is disabled from the project
return project.styling;
};
export const getDefaultLanguageCode = (survey: TEnvironmentStateSurvey): string | undefined => {
const defaultSurveyLanguage = survey.languages.find((surveyLanguage) => {
return surveyLanguage.default;
});
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};
export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: string): string | undefined => {
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
if (!language) return "default";
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === language.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
);
});
if (selectedLanguage?.default) {
return "default";
}
if (
!selectedLanguage ||
!selectedLanguage.enabled ||
!availableLanguageCodes.includes(selectedLanguage.language.code)
) {
return undefined;
}
return selectedLanguage.language.code;
};
export const shouldDisplayBasedOnPercentage = (displayPercentage: number): boolean => {
const randomNum = Math.floor(Math.random() * 10000) / 100;
return randomNum <= displayPercentage;
};
export const isNowExpired = (expirationDate: Date): boolean => {
return new Date() >= expirationDate;
};

View File

@@ -1,118 +0,0 @@
/* eslint-disable no-console -- logging required for error logging */
import { ApiClient } from "@/lib/common/api";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys } from "@/lib/common/utils";
import type { TConfigInput, TEnvironmentState } from "@/types/config";
import { type ApiErrorResponse, type Result, err, ok } from "@/types/error";
let environmentStateSyncIntervalId: number | null = null;
/**
* Fetch the environment state from the backend
* @param appUrl - The app URL
* @param environmentId - The environment ID
* @returns The environment state
* @throws NetworkError
*/
export const fetchEnvironmentState = async ({
appUrl,
environmentId,
}: TConfigInput): Promise<Result<TEnvironmentState, ApiErrorResponse>> => {
const url = `${appUrl}/api/v1/client/${environmentId}/environment`;
const api = new ApiClient({ appUrl, environmentId, isDebug: false });
try {
const response = await api.getEnvironmentState();
if (!response.ok) {
return err({
code: response.error.code,
status: response.error.status,
message: "Error syncing with backend",
url: new URL(url),
responseMessage: response.error.message,
});
}
return ok(response.data);
} catch (e: unknown) {
const errorTyped = e as ApiErrorResponse;
return err({
code: "network_error",
message: errorTyped.message,
status: 500,
url: new URL(url),
responseMessage: errorTyped.responseMessage ?? "Network error",
});
}
};
/**
* Add a listener to check if the environment state has expired with a certain interval
*/
export const addEnvironmentStateExpiryCheckListener = (): void => {
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
const updateInterval = 1000 * 60; // every minute
if (environmentStateSyncIntervalId === null) {
const intervalHandler = async (): Promise<void> => {
const expiresAt = appConfig.get().environment.expiresAt;
try {
// check if the environmentState has not expired yet
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- expiresAt is checked for null
if (expiresAt && new Date(expiresAt) >= new Date()) {
return;
}
logger.debug("Environment State has expired. Starting sync.");
const personState = appConfig.get().user;
const environmentState = await fetchEnvironmentState({
appUrl: appConfig.get().appUrl,
environmentId: appConfig.get().environmentId,
});
if (environmentState.ok) {
const { data: state } = environmentState;
const filteredSurveys = filterSurveys(state, personState);
appConfig.update({
...appConfig.get(),
environment: state,
filteredSurveys,
});
} else {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error is an ApiErrorResponse
throw environmentState.error;
}
} catch (e) {
console.error(`Error during expiry check: `, e);
logger.debug("Extending config and try again later.");
const existingConfig = appConfig.get();
appConfig.update({
...existingConfig,
environment: {
...existingConfig.environment,
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
},
});
}
};
environmentStateSyncIntervalId = setInterval(
() => void intervalHandler(),
updateInterval
) as unknown as number;
}
};
export const clearEnvironmentStateExpiryCheckListener = (): void => {
if (environmentStateSyncIntervalId) {
clearInterval(environmentStateSyncIntervalId);
environmentStateSyncIntervalId = null;
}
};

View File

@@ -1,278 +0,0 @@
// state.test.ts
import { ApiClient } from "@/lib/common/api";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys } from "@/lib/common/utils";
import {
addEnvironmentStateExpiryCheckListener,
clearEnvironmentStateExpiryCheckListener,
fetchEnvironmentState,
} from "@/lib/environment/state";
import type { TEnvironmentState } from "@/types/config";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock the FormbricksAPI so we can control environment.getState
vi.mock("@/lib/common/api", () => ({
ApiClient: vi.fn().mockImplementation(() => ({
getEnvironmentState: vi.fn(),
})),
}));
// Mock logger (so we dont spam console)
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => {
return {
debug: vi.fn(),
error: vi.fn(),
};
}),
},
}));
// Mock filterSurveys
vi.mock("@/lib/common/utils", () => ({
filterSurveys: vi.fn(),
}));
// Mock RNConfig
vi.mock("@/lib/common/config", () => {
return {
RN_ASYNC_STORAGE_KEY: "formbricks-react-native",
RNConfig: {
getInstance: vi.fn(() => ({
get: vi.fn(),
update: vi.fn(),
})),
},
};
});
describe("environment/state.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// Use real timers so we don't pollute subsequent test code
vi.useRealTimers();
});
describe("fetchEnvironmentState()", () => {
test("returns ok(...) with environment state", async () => {
// Setup mock
(ApiClient as unknown as Mock).mockImplementationOnce(() => {
return {
getEnvironmentState: vi.fn().mockResolvedValue({
ok: true,
data: { data: { foo: "bar" }, expiresAt: new Date(Date.now() + 1000 * 60 * 30) },
}),
};
});
const result = await fetchEnvironmentState({
appUrl: "https://fake.host",
environmentId: "env_123",
});
expect(result.ok).toBe(true);
if (result.ok) {
const val: TEnvironmentState = result.data;
expect(val.data).toEqual({ foo: "bar" });
expect(val.expiresAt).toBeInstanceOf(Date);
}
});
test("returns err(...) if environment.getState is not ok", async () => {
const mockError = { code: "forbidden", status: 403, message: "Access denied" };
(ApiClient as unknown as Mock).mockImplementationOnce(() => {
return {
getEnvironmentState: vi.fn().mockResolvedValue({
ok: false,
error: mockError,
}),
};
});
const result = await fetchEnvironmentState({
appUrl: "https://fake.host",
environmentId: "env_123",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(mockError.code);
expect(result.error.status).toBe(mockError.status);
expect(result.error.responseMessage).toBe(mockError.message);
}
});
test("returns err(...) on network error catch", async () => {
const mockNetworkError = {
code: "network_error",
message: "Timeout",
responseMessage: "Network fail",
};
(ApiClient as unknown as Mock).mockImplementationOnce(() => {
return {
getEnvironmentState: vi.fn().mockRejectedValue(mockNetworkError),
};
});
const result = await fetchEnvironmentState({
appUrl: "https://fake.host",
environmentId: "env_123",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(mockNetworkError.code);
expect(result.error.message).toBe(mockNetworkError.message);
expect(result.error.responseMessage).toBe(mockNetworkError.responseMessage);
}
});
});
describe("addEnvironmentStateExpiryCheckListener()", () => {
let mockRNConfig: MockInstance<() => RNConfig>;
let mockLoggerInstance: MockInstance<() => Logger>;
const mockLogger = {
debug: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockRNConfig = vi.spyOn(RNConfig, "getInstance");
const mockConfig = {
get: vi.fn().mockReturnValue({
environment: {
expiresAt: new Date(Date.now() + 60_000), // Not expired for now
},
user: {},
environmentId: "env_123",
appUrl: "https://fake.host",
}),
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
mockLoggerInstance = vi.spyOn(Logger, "getInstance");
mockLoggerInstance.mockReturnValue(mockLogger as unknown as Logger);
});
afterEach(() => {
clearEnvironmentStateExpiryCheckListener(); // clear after each test
});
test("starts interval check and updates state when expired", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environment: {
expiresAt: new Date(Date.now() - 1000).toISOString(), // expired
},
appUrl: "https://test.com",
environmentId: "env_123",
user: { data: {} },
}),
update: vi.fn(),
};
const mockNewState = {
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 30).toISOString(),
},
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
(ApiClient as Mock).mockImplementation(() => ({
getEnvironmentState: vi.fn().mockResolvedValue({
ok: true,
data: mockNewState,
}),
}));
(filterSurveys as Mock).mockReturnValue([]);
// Add listener
addEnvironmentStateExpiryCheckListener();
// Fast-forward time
await vi.advanceTimersByTimeAsync(1000 * 60);
// Verify the update was called
expect(mockConfig.update).toHaveBeenCalled();
});
test("extends expiry on error", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environment: {
expiresAt: new Date(Date.now() - 1000).toISOString(),
},
appUrl: "https://test.com",
environmentId: "env_123",
}),
update: vi.fn(),
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
// Mock API to throw an error
(ApiClient as Mock).mockImplementation(() => ({
getEnvironmentState: vi.fn().mockRejectedValue(new Error("Network error")),
}));
addEnvironmentStateExpiryCheckListener();
// Fast-forward time
await vi.advanceTimersByTimeAsync(1000 * 60);
// Verify the config was updated with extended expiry
expect(mockConfig.update).toHaveBeenCalled();
});
test("does not fetch new state if not expired", async () => {
const futureDate = new Date(Date.now() + 1000 * 60 * 60); // 1 hour in future
const mockConfig = {
get: vi.fn().mockReturnValue({
environment: {
expiresAt: futureDate.toISOString(),
},
appUrl: "https://test.com",
environmentId: "env_123",
}),
update: vi.fn(),
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
const apiMock = vi.fn().mockImplementation(() => ({
getEnvironmentState: vi.fn(),
}));
(ApiClient as Mock).mockImplementation(apiMock);
addEnvironmentStateExpiryCheckListener();
// Fast-forward time by less than expiry
await vi.advanceTimersByTimeAsync(1000 * 60);
expect(mockConfig.update).not.toHaveBeenCalled();
});
test("clears interval when clearEnvironmentStateExpiryCheckListener is called", () => {
const clearIntervalSpy = vi.spyOn(global, "clearInterval");
addEnvironmentStateExpiryCheckListener();
clearEnvironmentStateExpiryCheckListener();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,114 +0,0 @@
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
import { SurveyStore } from "@/lib/survey/store";
import type { TEnvironmentStateSurvey } from "@/types/config";
import { type InvalidCodeError, type NetworkError, type Result, err, okVoid } from "@/types/error";
import { fetch } from "@react-native-community/netinfo";
/**
* Triggers the display of a survey if it meets the display percentage criteria
* @param survey - The survey configuration to potentially display
*/
export const triggerSurvey = (survey: TEnvironmentStateSurvey): void => {
const surveyStore = SurveyStore.getInstance();
const logger = Logger.getInstance();
// Check if the survey should be displayed based on displayPercentage
if (survey.displayPercentage) {
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
if (!shouldDisplaySurvey) {
logger.debug(`Survey display of "${survey.name}" skipped based on displayPercentage.`);
return; // skip displaying the survey
}
}
surveyStore.setSurvey(survey);
};
/**
* Tracks an action name and triggers associated surveys
* @param name - The name of the action to track
* @param alias - Optional alias for the action name
* @returns Result indicating success or network error
*/
export const trackAction = (name: string, alias?: string): Result<void, NetworkError> => {
const logger = Logger.getInstance();
const appConfig = RNConfig.getInstance();
const aliasName = alias ?? name;
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = appConfig.get().filteredSurveys;
if (Boolean(activeSurveys) && activeSurveys.length > 0) {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.actionClass.name === name) {
triggerSurvey(survey);
}
}
}
} else {
logger.debug("No active surveys to display");
}
return okVoid();
};
/**
* Tracks an action by its code and triggers associated surveys (used for code actions only)
* @param code - The action code to track
* @returns Result indicating success, network error, or invalid code error
*/
export const track = async (
code: string
): Promise<
| Result<void, NetworkError>
| Result<void, InvalidCodeError>
| Result<void, { code: "error"; message: string }>
> => {
try {
const appConfig = RNConfig.getInstance();
const netInfo = await fetch();
if (!netInfo.isConnected) {
return err({
code: "network_error",
status: 500,
message: "No internet connection. Please check your connection and try again.",
responseMessage: "No internet connection. Please check your connection and try again.",
url: new URL(`${appConfig.get().appUrl}/js/surveys.umd.cjs`),
});
}
const {
environment: {
data: { actionClasses = [] },
},
} = appConfig.get();
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
const actionClass = codeActionClasses.find((action) => action.key === code);
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.`,
});
}
return trackAction(actionClass.name, code);
} catch (error) {
const logger = Logger.getInstance();
logger.error(`Error tracking action ${error as string}`);
return err({
code: "error",
message: "Error tracking action",
});
}
};

View File

@@ -1,46 +0,0 @@
import type { TEnvironmentStateSurvey } from "@/types/config";
type Listener = (state: TEnvironmentStateSurvey | null, prevSurvey: TEnvironmentStateSurvey | null) => void;
export class SurveyStore {
private static instance: SurveyStore | undefined;
private survey: TEnvironmentStateSurvey | null = null;
private listeners = new Set<Listener>();
static getInstance(): SurveyStore {
if (!SurveyStore.instance) {
SurveyStore.instance = new SurveyStore();
}
return SurveyStore.instance;
}
public getSurvey(): TEnvironmentStateSurvey | null {
return this.survey;
}
public setSurvey(survey: TEnvironmentStateSurvey): void {
const prevSurvey = this.survey;
if (prevSurvey?.id !== survey.id) {
this.survey = survey;
this.listeners.forEach((listener) => {
listener(this.survey, prevSurvey);
});
}
}
public resetSurvey(): void {
const prevSurvey = this.survey;
if (prevSurvey !== null) {
this.survey = null;
this.listeners.forEach((listener) => {
listener(this.survey, prevSurvey);
});
}
}
public subscribe(listener: Listener) {
this.listeners.add(listener);
// Unsubscribe
return () => this.listeners.delete(listener);
}
}

View File

@@ -1,2 +0,0 @@
export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
export const mockSurveyName = "Test Survey";

View File

@@ -1,181 +0,0 @@
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
import { track, trackAction, triggerSurvey } from "@/lib/survey/action";
import { SurveyStore } from "@/lib/survey/store";
import { type TEnvironmentStateSurvey } from "@/types/config";
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/lib/common/config", () => ({
RNConfig: {
getInstance: vi.fn(() => ({
get: vi.fn(),
})),
},
}));
vi.mock("@/lib/survey/store", () => ({
SurveyStore: {
getInstance: vi.fn(() => ({
setSurvey: vi.fn(),
})),
},
}));
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => {
return {
debug: vi.fn(),
};
}),
},
}));
vi.mock("@/lib/common/utils", () => ({
shouldDisplayBasedOnPercentage: vi.fn(),
}));
vi.mock("@react-native-community/netinfo", () => ({
fetch: vi.fn(() => ({
isConnected: true,
})),
}));
describe("survey/action.ts", () => {
const mockSurvey = {
id: "survey_001",
name: "Test Survey",
displayPercentage: 50,
triggers: [
{
actionClass: { name: "testAction" },
},
],
};
const mockAppConfig = {
get: vi.fn(),
};
const mockSurveyStore = {
setSurvey: vi.fn(),
};
const mockLogger = {
debug: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
const getInstanceRn = vi.spyOn(RNConfig, "getInstance");
const getInstanceSurveyStore = vi.spyOn(SurveyStore, "getInstance");
const getInstanceLogger = vi.spyOn(Logger, "getInstance");
// Mock instances
getInstanceRn.mockReturnValue(mockAppConfig as unknown as RNConfig);
getInstanceSurveyStore.mockReturnValue(mockSurveyStore as unknown as SurveyStore);
getInstanceLogger.mockReturnValue(mockLogger as unknown as Logger);
});
describe("triggerSurvey", () => {
test("does not trigger survey if displayPercentage criteria is not met", () => {
const shouldDisplayBasedOnPercentageMock = vi.mocked(shouldDisplayBasedOnPercentage);
shouldDisplayBasedOnPercentageMock.mockReturnValueOnce(false);
triggerSurvey(mockSurvey as unknown as TEnvironmentStateSurvey);
// Ensure survey is not set
expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
'Survey display of "Test Survey" skipped based on displayPercentage.'
);
});
test("triggers survey if displayPercentage criteria is met", () => {
// Mock `shouldDisplayBasedOnPercentage` to return true
const shouldDisplayBasedOnPercentageMock = vi.mocked(shouldDisplayBasedOnPercentage);
shouldDisplayBasedOnPercentageMock.mockReturnValueOnce(true);
triggerSurvey(mockSurvey as unknown as TEnvironmentStateSurvey);
// Ensure survey is set
expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey);
});
});
describe("trackAction", () => {
const mockActiveSurveys = [mockSurvey];
beforeEach(() => {
mockAppConfig.get.mockReturnValue({
filteredSurveys: mockActiveSurveys,
});
});
test("triggers survey associated with action name", () => {
(shouldDisplayBasedOnPercentage as unknown as Mock).mockReturnValue(true);
trackAction("testAction");
// Ensure triggerSurvey is called for the matching survey
expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey);
});
test("does not trigger survey if no active surveys are found", () => {
mockAppConfig.get.mockReturnValue({
filteredSurveys: [],
});
trackAction("testAction");
// Ensure no surveys are triggered
expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith("No active surveys to display");
});
test("logs tracked action name", () => {
trackAction("testAction");
expect(mockLogger.debug).toHaveBeenCalledWith('Formbricks: Action "testAction" tracked');
});
});
describe("track", () => {
const mockActionClasses = [
{
key: "testCode",
type: "code",
name: "testAction",
},
];
beforeEach(() => {
mockAppConfig.get.mockReturnValue({
environment: {
data: { actionClasses: mockActionClasses },
},
});
});
test("tracks a valid action by code", async () => {
const result = await track("testCode");
expect(result.ok).toBe(true);
});
test("returns error for invalid action code", async () => {
const result = await track("invalidCode");
expect(result.ok).toBe(false);
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."
);
}
});
});
});

View File

@@ -1,129 +0,0 @@
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
import { SurveyStore } from "@/lib/survey/store";
import type { TEnvironmentStateSurvey } from "@/types/config";
import { beforeEach, describe, expect, test, vi } from "vitest";
describe("SurveyStore", () => {
let store: SurveyStore;
beforeEach(() => {
// Reset the singleton instance before each test
// @ts-expect-error accessing private static property
SurveyStore.instance = undefined;
store = SurveyStore.getInstance();
});
describe("getInstance", () => {
test("returns singleton instance", () => {
const instance1 = SurveyStore.getInstance();
const instance2 = SurveyStore.getInstance();
expect(instance1).toBe(instance2);
});
});
describe("getSurvey", () => {
test("returns null when no survey is set", () => {
expect(store.getSurvey()).toBeNull();
});
test("returns current survey when set", () => {
const mockSurvey: TEnvironmentStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TEnvironmentStateSurvey;
store.setSurvey(mockSurvey);
expect(store.getSurvey()).toBe(mockSurvey);
});
});
describe("setSurvey", () => {
test("updates survey and notifies listeners when survey changes", () => {
const listener = vi.fn();
const mockSurvey: TEnvironmentStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TEnvironmentStateSurvey;
store.subscribe(listener);
store.setSurvey(mockSurvey);
expect(listener).toHaveBeenCalledWith(mockSurvey, null);
expect(store.getSurvey()).toBe(mockSurvey);
});
test("does not notify listeners when setting same survey", () => {
const listener = vi.fn();
const mockSurvey: TEnvironmentStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TEnvironmentStateSurvey;
store.setSurvey(mockSurvey);
store.subscribe(listener);
store.setSurvey(mockSurvey);
expect(listener).not.toHaveBeenCalled();
});
});
describe("resetSurvey", () => {
test("resets survey to null and notifies listeners", () => {
const listener = vi.fn();
const mockSurvey: TEnvironmentStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TEnvironmentStateSurvey;
store.setSurvey(mockSurvey);
store.subscribe(listener);
store.resetSurvey();
expect(listener).toHaveBeenCalledWith(null, mockSurvey);
expect(store.getSurvey()).toBeNull();
});
test("does not notify listeners when already null", () => {
const listener = vi.fn();
store.subscribe(listener);
store.resetSurvey();
expect(listener).not.toHaveBeenCalled();
expect(store.getSurvey()).toBeNull();
});
});
describe("subscribe", () => {
test("adds listener and returns unsubscribe function", () => {
const listener = vi.fn();
const mockSurvey: TEnvironmentStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TEnvironmentStateSurvey;
const unsubscribe = store.subscribe(listener);
store.setSurvey(mockSurvey);
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TEnvironmentStateSurvey);
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called after unsubscribe
});
test("multiple listeners receive updates", () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const mockSurvey: TEnvironmentStateSurvey = {
id: mockSurveyId,
name: mockSurveyName,
} as TEnvironmentStateSurvey;
store.subscribe(listener1);
store.subscribe(listener2);
store.setSurvey(mockSurvey);
expect(listener1).toHaveBeenCalledWith(mockSurvey, null);
expect(listener2).toHaveBeenCalledWith(mockSurvey, null);
});
});
});

View File

@@ -1,12 +0,0 @@
import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";
export const setAttributes = async (
attributes: Record<string, string>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
const updateQueue = UpdateQueue.getInstance();
updateQueue.updateAttributes(attributes);
void updateQueue.processUpdates();
return okVoid();
};

View File

@@ -1,55 +0,0 @@
import { RNConfig } from "@/lib/common/config";
import type { TUserState } from "@/types/config";
let userStateSyncIntervalId: number | null = null;
export const DEFAULT_USER_STATE_NO_USER_ID: TUserState = {
expiresAt: null,
data: {
userId: null,
contactId: null,
segments: [],
displays: [],
responses: [],
lastDisplayAt: null,
},
} as const;
/**
* Add a listener to check if the user state has expired with a certain interval
*/
export const addUserStateExpiryCheckListener = (): void => {
const config = RNConfig.getInstance();
const updateInterval = 1000 * 60; // every 60 seconds
if (userStateSyncIntervalId === null) {
const intervalHandler = (): void => {
const userId = config.get().user.data.userId;
if (!userId) {
return;
}
// extend the personState validity by 30 minutes:
config.update({
...config.get(),
user: {
...config.get().user,
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
},
});
};
userStateSyncIntervalId = setInterval(intervalHandler, updateInterval) as unknown as number;
}
};
/**
* Clear the person state expiry check listener
*/
export const clearUserStateExpiryCheckListener = (): void => {
if (userStateSyncIntervalId) {
clearInterval(userStateSyncIntervalId);
userStateSyncIntervalId = null;
}
};

View File

@@ -1,6 +0,0 @@
export const mockUserId1 = "user_123";
export const mockUserId2 = "user_456";
export const mockAttributes = {
name: "John Doe",
email: "john@example.com",
};

View File

@@ -1,7 +0,0 @@
export const mockUserId = "user_123";
export const mockEnvironmentId = "ew9ba7urnv7u3eo11k5c1z0r";
export const mockAppUrl = "https://app.formbricks.com";
export const mockAttributes = {
name: "John Doe",
email: "john@example.com",
};

View File

@@ -1,76 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { setAttributes } from "@/lib/user/attribute";
import { UpdateQueue } from "@/lib/user/update-queue";
export const mockAttributes = {
name: "John Doe",
email: "john@example.com",
};
// Mock the UpdateQueue
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => ({
updateAttributes: vi.fn(),
processUpdates: vi.fn(),
})),
},
}));
describe("User Attributes", () => {
const mockUpdateQueue = {
updateAttributes: vi.fn(),
processUpdates: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
const getInstanceUpdateQueue = vi.spyOn(UpdateQueue, "getInstance");
getInstanceUpdateQueue.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
});
describe("setAttributes", () => {
test("successfully updates attributes and triggers processing", async () => {
const result = await setAttributes(mockAttributes);
// Verify UpdateQueue methods were called correctly
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith(mockAttributes);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
// Verify result is ok
expect(result.ok).toBe(true);
});
test("processes multiple attribute updates", async () => {
const firstAttributes = { name: mockAttributes.name };
const secondAttributes = { email: mockAttributes.email };
await setAttributes(firstAttributes);
await setAttributes(secondAttributes);
expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledTimes(2);
expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(1, firstAttributes);
expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(2, secondAttributes);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalledTimes(2);
});
test("processes updates asynchronously", async () => {
const attributes = { name: mockAttributes.name };
// Mock processUpdates to be async
mockUpdateQueue.processUpdates.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, 100);
})
);
const result = await setAttributes(attributes);
expect(result.ok).toBe(true);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
// The function returns before processUpdates completes due to void operator
});
});
});

View File

@@ -1,103 +0,0 @@
import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { RNConfig } from "@/lib/common/config";
import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
const mockUserId = "user_123";
vi.mock("@/lib/common/config", () => ({
RNConfig: {
getInstance: vi.fn(() => ({
get: vi.fn(),
update: vi.fn(),
})),
},
}));
describe("User State Expiry Check Listener", () => {
let mockRNConfig: MockInstance<() => RNConfig>;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers(); // Simulate timers
mockRNConfig = vi.spyOn(RNConfig, "getInstance");
});
afterEach(() => {
clearUserStateExpiryCheckListener(); // Ensure cleanup after each test
});
test("should set an interval if not already set and update user state expiry when userId exists", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: { data: { userId: mockUserId } },
}),
update: vi.fn(),
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
addUserStateExpiryCheckListener();
// Fast-forward time by 1 minute (60,000 ms)
vi.advanceTimersByTime(60_000);
// Ensure config.update was called with extended expiry time
expect(mockConfig.update).toHaveBeenCalledWith({
user: {
data: { userId: mockUserId },
expiresAt: expect.any(Date) as Date,
},
});
});
test("should not update user state expiry if userId does not exist", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: { data: { userId: null } },
}),
update: vi.fn(),
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
addUserStateExpiryCheckListener();
vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
expect(mockConfig.update).not.toHaveBeenCalled(); // Ensures no update when no userId
});
test("should not set multiple intervals if already set", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: { data: { userId: mockUserId } },
}),
update: vi.fn(),
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
addUserStateExpiryCheckListener();
addUserStateExpiryCheckListener(); // Call again to check if it prevents multiple intervals
vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
expect(mockConfig.update).toHaveBeenCalledTimes(1);
});
test("should clear interval when clearUserStateExpiryCheckListener is called", () => {
const mockConfig = {
get: vi.fn(),
update: vi.fn(),
};
mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
addUserStateExpiryCheckListener();
clearUserStateExpiryCheckListener();
vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
expect(mockConfig.update).not.toHaveBeenCalled();
});
});

View File

@@ -1,161 +0,0 @@
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock";
import { RNConfig } from "@/lib/common/config";
import { sendUpdates } from "@/lib/user/update";
import { UpdateQueue } from "@/lib/user/update-queue";
// Mock dependencies
vi.mock("@/lib/common/config", () => ({
RNConfig: {
getInstance: vi.fn(() => ({
get: vi.fn(() => ({
user: {
data: {
userId: "mock-user-id",
},
},
})),
update: vi.fn(),
})),
},
}));
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/user/update", () => ({
sendUpdates: vi.fn(),
}));
describe("UpdateQueue", () => {
let updateQueue: UpdateQueue;
beforeEach(() => {
vi.clearAllMocks();
// Reset singleton instance
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- accessing private static property
(UpdateQueue as any).instance = null;
updateQueue = UpdateQueue.getInstance();
});
test("getInstance returns singleton instance", () => {
const instance1 = UpdateQueue.getInstance();
const instance2 = UpdateQueue.getInstance();
expect(instance1).toBe(instance2);
});
test("updateUserId sets userId correctly when updates is null", () => {
const userId = mockUserId1;
updateQueue.updateUserId(userId);
expect(updateQueue.getUpdates()).toEqual({
userId,
attributes: {},
});
});
test("updateUserId updates existing userId correctly", () => {
const userId1 = mockUserId1;
const userId2 = mockUserId2;
updateQueue.updateUserId(userId1);
updateQueue.updateUserId(userId2);
expect(updateQueue.getUpdates()).toEqual({
userId: userId2,
attributes: {},
});
});
test("updateAttributes sets attributes correctly when updates is null", () => {
const attributes = mockAttributes;
updateQueue.updateAttributes(attributes);
expect(updateQueue.getUpdates()).toEqual({
userId: "mock-user-id", // from mocked config
attributes,
});
});
test("updateAttributes merges with existing attributes", () => {
updateQueue.updateAttributes({ name: mockAttributes.name });
updateQueue.updateAttributes({ email: mockAttributes.email });
expect(updateQueue.getUpdates()).toEqual({
userId: "mock-user-id",
attributes: {
name: mockAttributes.name,
email: mockAttributes.email,
},
});
});
test("clearUpdates resets updates to null", () => {
updateQueue.updateAttributes({ name: mockAttributes.name });
updateQueue.clearUpdates();
expect(updateQueue.getUpdates()).toBeNull();
});
test("isEmpty returns true when updates is null", () => {
expect(updateQueue.isEmpty()).toBe(true);
});
test("isEmpty returns false when updates exist", () => {
updateQueue.updateAttributes({ name: mockAttributes.name });
expect(updateQueue.isEmpty()).toBe(false);
});
test("processUpdates debounces multiple calls", async () => {
// Call processUpdates multiple times in quick succession
(sendUpdates as Mock).mockReturnValue({
ok: true,
});
updateQueue.updateAttributes({ name: mockAttributes.name });
updateQueue.updateAttributes({ email: mockAttributes.email });
// Wait for debounce timeout
await new Promise((resolve) => {
setTimeout(resolve, 600);
});
await updateQueue.processUpdates();
// Should only be called once with the merged updates
expect(sendUpdates).toHaveBeenCalledTimes(1);
});
test("processUpdates handles language attribute specially when no userId", async () => {
const configUpdateMock = vi.fn();
(RNConfig.getInstance as Mock).mockImplementation(() => ({
get: vi.fn(() => ({
user: { data: { userId: "" } },
})),
update: configUpdateMock,
}));
updateQueue.updateAttributes({ language: "en" });
await updateQueue.processUpdates();
expect(configUpdateMock).toHaveBeenCalled();
});
test("processUpdates throws error when setting attributes without userId", async () => {
(RNConfig.getInstance as Mock).mockImplementation(() => ({
get: vi.fn(() => ({
user: { data: { userId: "" } },
})),
}));
updateQueue.updateAttributes({ name: mockAttributes.name });
await expect(updateQueue.processUpdates()).rejects.toThrow(
"Formbricks can't set attributes without a userId!"
);
});
});

View File

@@ -1,193 +0,0 @@
import {
mockAppUrl,
mockAttributes,
mockEnvironmentId,
mockUserId,
} from "@/lib/user/tests/__mocks__/update.mock";
import { ApiClient } from "@/lib/common/api";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { sendUpdates, sendUpdatesToBackend } from "@/lib/user/update";
import { type TUpdates } from "@/types/config";
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/lib/common/config", () => ({
RNConfig: {
getInstance: vi.fn(() => ({
get: vi.fn(),
update: vi.fn(),
})),
},
}));
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
debug: vi.fn(),
})),
},
}));
vi.mock("@/lib/common/utils", () => ({
filterSurveys: vi.fn(),
}));
vi.mock("@/lib/common/api", () => ({
ApiClient: vi.fn().mockImplementation(() => ({
createOrUpdateUser: vi.fn(),
})),
}));
describe("sendUpdatesToBackend", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("sends user updates to backend and returns updated state", async () => {
const mockResponse = {
ok: true,
data: {
state: {
data: {
userId: mockUserId,
attributes: mockAttributes,
},
},
},
};
(ApiClient as Mock).mockImplementation(() => ({
createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse),
}));
const result = await sendUpdatesToBackend({
appUrl: mockAppUrl,
environmentId: mockEnvironmentId,
updates: { userId: mockUserId, attributes: mockAttributes },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.state.data).toEqual({ userId: mockUserId, attributes: mockAttributes });
}
});
test("returns network error if API call fails", async () => {
const mockUpdates: TUpdates = { userId: mockUserId, attributes: mockAttributes };
(ApiClient as Mock).mockImplementation(() => ({
createOrUpdateUser: vi.fn().mockResolvedValue({
ok: false,
error: { code: "network_error", message: "Request failed", status: 500 },
}),
}));
const result = await sendUpdatesToBackend({
appUrl: mockAppUrl,
environmentId: mockEnvironmentId,
updates: mockUpdates,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.message).toBe("Error updating user with userId user_123");
}
});
test("throws error if network request fails", async () => {
const mockUpdates: TUpdates = { userId: mockUserId, attributes: { plan: "premium" } };
(ApiClient as Mock).mockImplementation(() => ({
createOrUpdateUser: vi.fn().mockRejectedValue(new Error("Network error")),
}));
await expect(
sendUpdatesToBackend({
appUrl: mockAppUrl,
environmentId: mockEnvironmentId,
updates: mockUpdates,
})
).rejects.toThrow("Network error");
});
});
describe("sendUpdates", () => {
beforeEach(() => {
(RNConfig.getInstance as Mock).mockImplementation(() => ({
get: vi.fn().mockReturnValue({
appUrl: mockAppUrl,
environmentId: mockEnvironmentId,
environment: {
data: {
surveys: [],
},
},
}),
update: vi.fn(),
}));
(Logger.getInstance as Mock).mockImplementation(() => ({
debug: vi.fn(),
}));
});
test("successfully processes updates", async () => {
const mockResponse = {
ok: true,
data: {
state: {
data: {
userId: mockUserId,
attributes: mockAttributes,
},
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
},
},
};
(ApiClient as Mock).mockImplementation(() => ({
createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse),
}));
const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
expect(result.ok).toBe(true);
});
test("handles backend errors", async () => {
const mockErrorResponse = {
ok: false,
error: {
code: "invalid_request",
status: 400,
message: "Invalid request",
},
};
(ApiClient as Mock).mockImplementation(() => ({
createOrUpdateUser: vi.fn().mockResolvedValue(mockErrorResponse),
}));
const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("invalid_request");
}
});
test("handles unexpected errors", async () => {
(ApiClient as Mock).mockImplementation(() => ({
createOrUpdate: vi.fn().mockRejectedValue(new Error("Unexpected error")),
}));
const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.status).toBe(500);
}
});
});

View File

@@ -1,169 +0,0 @@
import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { setup, tearDown } from "@/lib/common/setup";
import { UpdateQueue } from "@/lib/user/update-queue";
import { logout, setUserId } from "@/lib/user/user";
// Mock dependencies
vi.mock("@/lib/common/config", () => ({
RNConfig: {
getInstance: vi.fn(() => ({
get: vi.fn(),
})),
},
}));
vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
error: vi.fn(),
debug: vi.fn(),
})),
},
}));
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => ({
updateUserId: vi.fn(),
processUpdates: vi.fn(),
})),
},
}));
vi.mock("@/lib/common/setup", () => ({
tearDown: vi.fn(),
setup: vi.fn(),
}));
describe("user.ts", () => {
const mockUserId = "test-user-123";
const mockEnvironmentId = "env-123";
const mockAppUrl = "https://test.com";
let getInstanceConfigMock: MockInstance<() => RNConfig>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
let getInstanceUpdateQueueMock: MockInstance<() => UpdateQueue>;
beforeEach(() => {
vi.clearAllMocks();
getInstanceConfigMock = vi.spyOn(RNConfig, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance");
getInstanceUpdateQueueMock = vi.spyOn(UpdateQueue, "getInstance");
});
describe("setUserId", () => {
test("returns error if userId is already set", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: {
data: {
userId: "existing-user",
},
},
}),
};
const mockLogger = {
debug: vi.fn(),
error: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
const result = await setUserId(mockUserId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("forbidden");
expect(result.error.status).toBe(403);
}
expect(mockLogger.error).toHaveBeenCalled();
});
test("successfully sets userId when none exists", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: {
data: {
userId: null,
},
},
}),
};
const mockLogger = {
debug: vi.fn(),
error: vi.fn(),
};
const mockUpdateQueue = {
updateUserId: vi.fn(),
processUpdates: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
getInstanceUpdateQueueMock.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
const result = await setUserId(mockUserId);
expect(result.ok).toBe(true);
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
});
});
describe("logout", () => {
test("successfully sets up formbricks after logout", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: mockEnvironmentId,
appUrl: mockAppUrl,
user: { data: { userId: mockUserId } },
}),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
(setup as Mock).mockResolvedValue(undefined);
const result = await logout();
expect(tearDown).toHaveBeenCalled();
expect(setup).toHaveBeenCalledWith({
environmentId: mockEnvironmentId,
appUrl: mockAppUrl,
});
expect(result.ok).toBe(true);
});
test("returns error if setup fails", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: mockEnvironmentId,
appUrl: mockAppUrl,
user: { data: { userId: mockUserId } },
}),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
const mockError = { code: "network_error", message: "Failed to connect" };
(setup as Mock).mockRejectedValue(mockError);
const result = await logout();
expect(tearDown).toHaveBeenCalled();
expect(setup).toHaveBeenCalledWith({
environmentId: mockEnvironmentId,
appUrl: mockAppUrl,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(mockError);
}
});
});
});

View File

@@ -1,153 +0,0 @@
/* eslint-disable @typescript-eslint/no-empty-function -- required for singleton pattern */
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { sendUpdates } from "@/lib/user/update";
import type { TAttributes, TUpdates } from "@/types/config";
const logger = Logger.getInstance();
export class UpdateQueue {
private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null;
private readonly DEBOUNCE_DELAY = 500;
private constructor() {}
public static getInstance(): UpdateQueue {
if (!UpdateQueue.instance) {
UpdateQueue.instance = new UpdateQueue();
}
return UpdateQueue.instance;
}
public updateUserId(userId: string): void {
if (!this.updates) {
this.updates = {
userId,
attributes: {},
};
} else {
this.updates = {
...this.updates,
userId,
};
}
}
public updateAttributes(attributes: TAttributes): void {
const config = RNConfig.getInstance();
// Get userId from updates first, then fallback to config
const userId = this.updates?.userId ?? config.get().user.data.userId ?? "";
if (!this.updates) {
this.updates = {
userId,
attributes,
};
} else {
this.updates = {
...this.updates,
userId,
attributes: { ...this.updates.attributes, ...attributes },
};
}
}
public getUpdates(): TUpdates | null {
return this.updates;
}
public clearUpdates(): void {
this.updates = null;
}
public isEmpty(): boolean {
return !this.updates;
}
public async processUpdates(): Promise<void> {
if (!this.updates) {
return;
}
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
return new Promise((resolve, reject) => {
const handler = async (): Promise<void> => {
try {
let currentUpdates = { ...this.updates };
const config = RNConfig.getInstance();
if (Object.keys(currentUpdates).length > 0) {
// Get userId from either updates or config
const effectiveUserId = currentUpdates.userId ?? config.get().user.data.userId;
const isLanguageInUpdates = currentUpdates.attributes?.language;
if (!effectiveUserId && isLanguageInUpdates) {
// no user id set but the updates contain a language
// we need to set this language in the local config:
config.update({
...config.get(),
user: {
...config.get().user,
data: {
...config.get().user.data,
language: currentUpdates.attributes?.language,
},
},
});
logger.debug("Updated language successfully");
const { language: _, ...remainingAttributes } = currentUpdates.attributes ?? {};
// remove language from attributes
currentUpdates = {
...currentUpdates,
attributes: remainingAttributes,
};
}
if (Object.keys(currentUpdates.attributes ?? {}).length > 0 && !effectiveUserId) {
const errorMessage =
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function";
logger.error(errorMessage);
this.clearUpdates();
throw new Error(errorMessage);
}
// Only send updates if we have a userId (either from updates or local storage)
if (effectiveUserId) {
const result = await sendUpdates({
updates: {
userId: effectiveUserId,
attributes: currentUpdates.attributes ?? {},
},
});
if (result.ok) {
logger.debug("Updates sent successfully");
} else {
logger.error("Failed to send updates");
}
}
}
this.clearUpdates();
resolve();
} catch (error: unknown) {
logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
reject(error as Error);
}
};
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
});
}
}

View File

@@ -1,114 +0,0 @@
/* eslint-disable no-console -- required for logging errors */
import { ApiClient } from "@/lib/common/api";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys } from "@/lib/common/utils";
import { type TUpdates, type TUserState } from "@/types/config";
import { type ApiErrorResponse, type Result, err, ok, okVoid } from "@/types/error";
export const sendUpdatesToBackend = async ({
appUrl,
environmentId,
updates,
}: {
appUrl: string;
environmentId: string;
updates: TUpdates;
}): Promise<
Result<
{
state: TUserState;
messages?: string[];
},
ApiErrorResponse
>
> => {
const url = `${appUrl}/api/v1/client/${environmentId}/user`;
const api = new ApiClient({ appUrl, environmentId, isDebug: false });
try {
const response = await api.createOrUpdateUser({
userId: updates.userId,
attributes: updates.attributes,
});
if (!response.ok) {
return err({
code: response.error.code,
status: response.error.status,
message: `Error updating user with userId ${updates.userId}`,
url: new URL(url),
responseMessage: response.error.message,
});
}
return ok(response.data);
} catch (e: unknown) {
const errorTyped = e as { message?: string };
const error = err({
code: "network_error",
message: errorTyped.message ?? "Error fetching the person state",
status: 500,
url: new URL(url),
responseMessage: errorTyped.message ?? "Unknown error",
});
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
throw error.error;
}
};
export const sendUpdates = async ({
updates,
}: {
updates: TUpdates;
}): Promise<Result<void, ApiErrorResponse>> => {
const config = RNConfig.getInstance();
const logger = Logger.getInstance();
const { appUrl, environmentId } = config.get();
// update endpoint call
const url = `${appUrl}/api/v1/client/${environmentId}/user`;
try {
const updatesResponse = await sendUpdatesToBackend({ appUrl, environmentId, updates });
if (updatesResponse.ok) {
const userState = updatesResponse.data.state;
const filteredSurveys = filterSurveys(config.get().environment, userState);
// messages => string[] - contains the details of the attributes update
// for example, if the attribute "email" was being used for some user or not
const messages = updatesResponse.data.messages;
if (messages && messages.length > 0) {
for (const message of messages) {
logger.debug(`User update message: ${message}`);
}
}
config.update({
...config.get(),
user: {
...userState,
},
filteredSurveys,
});
return okVoid();
}
return err(updatesResponse.error);
} catch (e) {
console.error("error in sending updates: ", e);
return err({
code: "network_error",
message: "Error sending updates",
status: 500,
url: new URL(url),
responseMessage: "Unknown error",
});
}
};

View File

@@ -1,60 +0,0 @@
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { setup, tearDown } from "@/lib/common/setup";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type ApiErrorResponse, type NetworkError, type Result, err, okVoid } from "@/types/error";
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
export const setUserId = async (userId: string): Promise<Result<void, ApiErrorResponse>> => {
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
const updateQueue = UpdateQueue.getInstance();
const {
data: { userId: currentUserId },
} = appConfig.get().user;
if (currentUserId) {
logger.error(
"A userId is already set in formbricks, please first call the logout function and then set a new userId"
);
return err({
code: "forbidden",
message: "User already set",
responseMessage: "User already set",
status: 403,
});
}
updateQueue.updateUserId(userId);
void updateQueue.processUpdates();
return okVoid();
};
export const logout = async (): Promise<Result<void, NetworkError>> => {
const logger = Logger.getInstance();
const appConfig = RNConfig.getInstance();
const { userId } = appConfig.get().user.data;
if (!userId) {
logger.debug("No userId is set, please use the setUserId function to set a userId first");
return okVoid();
}
logger.debug("Resetting state & getting new state from backend");
const initParams = {
environmentId: appConfig.get().environmentId,
appUrl: appConfig.get().appUrl,
};
// logout the user, remove user state and setup formbricks again
await tearDown();
try {
await setup(initParams);
return okVoid();
} catch (e) {
return err(e as NetworkError);
}
};

View File

@@ -1,13 +0,0 @@
import { TUserState } from "@/types/config";
import { ApiErrorResponse } from "@/types/error";
export type ApiResponse = ApiSuccessResponse | ApiErrorResponse;
export interface ApiSuccessResponse<T = Record<string, unknown>> {
data: T;
}
export interface CreateOrUpdateUserResponse {
state: TUserState;
messages?: string[];
}

View File

@@ -1,163 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies -- required for Prisma types */
import type { ActionClass, Language, Project, Segment, Survey, SurveyLanguage } from "@prisma/client";
import { z } from "zod";
import { type TResponseUpdate } from "@/types/response";
import { type TFileUploadParams } from "@/types/storage";
export type TEnvironmentStateSurvey = Pick<
Survey,
| "id"
| "name"
| "welcomeCard"
| "questions"
| "variables"
| "type"
| "showLanguageSwitch"
| "endings"
| "autoClose"
| "status"
| "recontactDays"
| "displayLimit"
| "displayOption"
| "hiddenFields"
| "delay"
| "projectOverwrites"
> & {
languages: (SurveyLanguage & { language: Language })[];
triggers: { actionClass: ActionClass }[];
segment?: Segment;
displayPercentage: number;
type: "link" | "app";
styling?: TSurveyStyling;
};
export type TEnvironmentStateProject = Pick<
Project,
"id" | "recontactDays" | "clickOutsideClose" | "darkOverlay" | "placement" | "inAppSurveyBranding"
> & {
styling: TProjectStyling;
};
export type TEnvironmentStateActionClass = Pick<ActionClass, "id" | "key" | "type" | "name" | "noCodeConfig">;
export interface TEnvironmentState {
expiresAt: Date;
data: {
surveys: TEnvironmentStateSurvey[];
actionClasses: TEnvironmentStateActionClass[];
project: TEnvironmentStateProject;
};
}
export interface TUserState {
expiresAt: Date | null;
data: {
userId: string | null;
contactId: string | null;
segments: string[];
displays: { surveyId: string; createdAt: Date }[];
responses: string[];
lastDisplayAt: Date | null;
language?: string;
};
}
export interface TConfig {
environmentId: string;
appUrl: string;
environment: TEnvironmentState;
user: TUserState;
filteredSurveys: TEnvironmentStateSurvey[];
status: {
value: "success" | "error";
expiresAt: Date | null;
};
}
export type TConfigUpdateInput = Omit<TConfig, "status"> & {
status?: {
value: "success" | "error";
expiresAt: Date | null;
};
};
export type TAttributes = Record<string, string>;
export interface TConfigInput {
environmentId: string;
appUrl: string;
}
export interface TStylingColor {
light: string;
dark?: string | null | undefined;
}
export interface TBaseStyling {
brandColor?: TStylingColor | null;
questionColor?: TStylingColor | null;
inputColor?: TStylingColor | null;
inputBorderColor?: TStylingColor | null;
cardBackgroundColor?: TStylingColor | null;
cardBorderColor?: TStylingColor | null;
cardShadowColor?: TStylingColor | null;
highlightBorderColor?: TStylingColor | null;
isDarkModeEnabled?: boolean | null;
roundness?: number | null;
cardArrangement?: {
linkSurveys: "casual" | "straight" | "simple";
appSurveys: "casual" | "straight" | "simple";
} | null;
background?: {
bg?: string | null;
bgType?: "animation" | "color" | "image" | "upload" | null;
brightness?: number | null;
} | null;
hideProgressBar?: boolean | null;
isLogoHidden?: boolean | null;
}
export interface TProjectStyling extends TBaseStyling {
allowStyleOverwrite: boolean;
}
export interface TSurveyStyling extends TBaseStyling {
overwriteThemeStyling?: boolean | null;
}
export interface TWebViewOnMessageData {
onFinished?: boolean | null;
onDisplay?: boolean | null;
onResponse?: boolean | null;
responseUpdate?: TResponseUpdate | null;
onRetry?: boolean | null;
onClose?: boolean | null;
onFileUpload?: boolean | null;
fileUploadParams?: TFileUploadParams | null;
uploadId?: string | null;
}
export const ZJsRNWebViewOnMessageData = z.object({
onFinished: z.boolean().nullish(),
onDisplayCreated: z.boolean().nullish(),
onResponseCreated: z.boolean().nullish(),
onClose: z.boolean().nullish(),
onFilePick: z.boolean().nullish(),
fileUploadParams: z
.object({
allowedFileExtensions: z.string().nullish(),
allowMultipleFiles: z.boolean().nullish(),
})
.nullish(),
onOpenExternalURL: z.boolean().nullish(),
onOpenExternalURLParams: z
.object({
url: z.string(),
})
.nullish(),
});
export interface TUpdates {
userId: string;
attributes?: TAttributes;
}

View File

@@ -1,66 +0,0 @@
export interface ResultError<T> {
ok: false;
error: T;
}
export interface ResultOk<T> {
ok: true;
value: T;
}
export type Result<T, E = Error> = { ok: true; data: T } | { ok: false; error: E };
export const ok = <T, E>(data: T): Result<T, E> => ({ ok: true, data });
export const okVoid = <E>(): Result<void, E> => ({ ok: true, data: undefined });
export const err = <E = Error>(error: E): ResultError<E> => ({
ok: false,
error,
});
export interface ApiErrorResponse {
code:
| "not_found"
| "gone"
| "bad_request"
| "internal_server_error"
| "unauthorized"
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "network_error"
| "too_many_requests";
message: string;
status: number;
url?: URL;
details?: Record<string, string | string[] | number | number[] | boolean | boolean[]>;
responseMessage?: string;
}
export interface MissingFieldError {
code: "missing_field";
field: string;
}
export interface MissingPersonError {
code: "missing_person";
message: string;
}
export interface NetworkError {
code: "network_error";
status: number;
message: string;
url: URL;
responseMessage: string;
}
export interface NotSetupError {
code: "not_setup";
message: string;
}
export interface InvalidCodeError {
code: "invalid_code";
message: string;
}

View File

@@ -1,44 +0,0 @@
import { z } from "zod";
export type TResponseData = Record<string, string | number | string[] | Record<string, string>>;
export type TResponseTtc = Record<string, number>;
export type TResponseVariables = Record<string, string | number>;
export type TResponseHiddenFieldValue = Record<string, string | number | string[]>;
export interface TResponseUpdate {
finished: boolean;
data: TResponseData;
language?: string;
variables?: TResponseVariables;
ttc?: TResponseTtc;
meta?: { url?: string; source?: string; action?: string };
hiddenFields?: TResponseHiddenFieldValue;
displayId?: string | null;
endingId?: string | null;
}
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
export const ZResponseVariables = z.record(z.union([z.string(), z.number()]));
export const ZResponseTtc = z.record(z.number());
export const ZResponseHiddenFieldValue = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
export const ZResponseUpdate = z.object({
finished: z.boolean(),
data: ZResponseData,
language: z.string().optional(),
variables: ZResponseVariables.optional(),
ttc: ZResponseTtc.optional(),
meta: z
.object({
url: z.string().optional(),
source: z.string().optional(),
action: z.string().optional(),
})
.optional(),
hiddenFields: ZResponseHiddenFieldValue.optional(),
displayId: z.string().nullish(),
endingId: z.string().nullish(),
});

View File

@@ -1,35 +0,0 @@
import { z } from "zod";
export interface TUploadFileConfig {
allowedFileExtensions?: string[] | undefined;
surveyId?: string | undefined;
}
export interface TUploadFileResponse {
data: {
signedUrl: string;
fileUrl: string;
signingData: {
signature: string;
timestamp: number;
uuid: string;
} | null;
updatedFileName: string;
presignedFields?: Record<string, string> | undefined;
};
}
export interface TFileUploadParams {
file: { type: string; name: string; base64: string };
params: TUploadFileConfig;
}
export const ZUploadFileConfig = z.object({
allowedFileExtensions: z.array(z.string()).optional(),
surveyId: z.string().optional(),
});
export const ZFileUploadParams = z.object({
file: z.object({ type: z.string(), name: z.string(), base64: z.string() }),
params: ZUploadFileConfig,
});

View File

@@ -1,58 +0,0 @@
import type { TEnvironmentStateSurvey, TProjectStyling, TSurveyStyling } from "@/types/config";
import type { TResponseData, TResponseUpdate } from "@/types/response";
import type { TFileUploadParams, TUploadFileConfig } from "@/types/storage";
import { type TJsFileUploadParams } from "../../../types/js";
export interface SurveyBaseProps {
survey: TEnvironmentStateSurvey;
styling: TSurveyStyling | TProjectStyling;
isBrandingEnabled: boolean;
getSetIsError?: (getSetError: (value: boolean) => void) => void;
getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void;
getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void;
getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void;
onDisplay?: () => void;
onResponse?: (response: TResponseUpdate) => void;
onFinished?: () => void;
onClose?: () => void;
onRetry?: () => void;
autoFocus?: boolean;
isRedirectDisabled?: boolean;
prefillResponseData?: TResponseData;
skipPrefilled?: boolean;
languageCode: string;
onFileUpload: (file: TFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
responseCount?: number;
isCardBorderVisible?: boolean;
startAtQuestionId?: string;
clickOutside?: boolean;
darkOverlay?: boolean;
hiddenFieldsRecord?: TResponseData;
shouldResetQuestionId?: boolean;
fullSizeCards?: boolean;
}
export interface SurveyInlineProps extends SurveyBaseProps {
containerId: string;
placement: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
}
export interface SurveyContainerProps extends Omit<SurveyBaseProps, "onFileUpload"> {
appUrl?: string;
environmentId?: string;
userId?: string;
contactId?: string;
onDisplayCreated?: () => void | Promise<void>;
onResponseCreated?: () => void | Promise<void>;
onFileUpload?: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
onOpenExternalURL?: (url: string) => void | Promise<void>;
mode?: "modal" | "inline";
containerId?: string;
clickOutside?: boolean;
darkOverlay?: boolean;
placement?: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
action?: string;
singleUseId?: string;
singleUseResponseId?: string;
isWebEnvironment?: boolean;
}

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"strict": true
},
"exclude": ["dist", "build", "node_modules"],
"extends": "@formbricks/config-typescript/react-native-library.json",
"include": [".", "../database/src/index.ts"]
}

View File

@@ -1,46 +0,0 @@
import { resolve } from "node:path";
import { type UserConfig, defineConfig } from "vite";
import dts from "vite-plugin-dts";
const config = (): UserConfig => {
return defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
optimizeDeps: {
exclude: ["react-native"],
},
build: {
emptyOutDir: false,
minify: "terser",
rollupOptions: {
external: [
"react",
"react-native",
"react-dom",
"react-native-webview",
"@react-native-async-storage/async-storage",
],
},
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "formbricksReactNative",
formats: ["es", "cjs"],
fileName: "index",
},
},
plugins: [dts({ rollupTypes: true, bundledPackages: ["@formbricks/types"] })],
test: {
setupFiles: ["./vitest.setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/lib/**/*.ts"],
},
},
});
};
export default config;

View File

@@ -1,28 +0,0 @@
import { afterEach, beforeEach, vi } from "vitest";
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
// Mock react-native
vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));
// Mock react-native-webview
vi.mock("react-native-webview", () => ({
WebView: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));