mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-17 09:51:14 -05:00
Compare commits
1 Commits
validation
...
fix/7494-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2837bccec |
@@ -64,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { project } = useEnvironment();
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
|
||||
@@ -1625,7 +1625,6 @@ checksums:
|
||||
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
|
||||
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
|
||||
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
|
||||
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
|
||||
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
|
||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
|
||||
"subheading": "Zwischenüberschrift",
|
||||
"subtract": "Subtrahieren -",
|
||||
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
|
||||
"survey_completed_heading": "Umfrage abgeschlossen",
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Styling set to theme styles",
|
||||
"subheading": "Subheading",
|
||||
"subtract": "Subtract -",
|
||||
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
|
||||
"survey_completed_heading": "Survey Completed",
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Restar -",
|
||||
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
|
||||
"survey_completed_heading": "Encuesta completada",
|
||||
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
|
||||
"survey_display_settings": "Ajustes de visualización de la encuesta",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
|
||||
"subheading": "Sous-titre",
|
||||
"subtract": "Soustraire -",
|
||||
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
|
||||
"survey_completed_heading": "Enquête terminée",
|
||||
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
|
||||
"survey_display_settings": "Paramètres d'affichage de l'enquête",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
|
||||
"subheading": "Alcím",
|
||||
"subtract": "Kivonás -",
|
||||
"survey_closed_message_heading_required": "Adjon hozzá egy címsort az egyéni felmérés lezárási üzenetéhez.",
|
||||
"survey_completed_heading": "A kérdőív kitöltve",
|
||||
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
|
||||
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
|
||||
"subheading": "サブ見出し",
|
||||
"subtract": "減算 -",
|
||||
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
|
||||
"survey_completed_heading": "フォームが完了しました",
|
||||
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
|
||||
"survey_display_settings": "フォーム表示設定",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
|
||||
"subheading": "Ondertitel",
|
||||
"subtract": "Aftrekken -",
|
||||
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
|
||||
"survey_completed_heading": "Enquête voltooid",
|
||||
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
|
||||
"survey_display_settings": "Enquêteweergave-instellingen",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
|
||||
"survey_completed_heading": "Pesquisa Concluída",
|
||||
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
|
||||
"survey_display_settings": "Configurações de Exibição da Pesquisa",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Estilo definido para estilos do tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
|
||||
"survey_completed_heading": "Inquérito Concluído",
|
||||
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
|
||||
"survey_display_settings": "Configurações de Exibição do Inquérito",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
|
||||
"subheading": "Subtitlu",
|
||||
"subtract": "Scade -",
|
||||
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
|
||||
"survey_completed_heading": "Sondaj Completat",
|
||||
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
|
||||
"survey_display_settings": "Setări de afișare a sondajului",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
|
||||
"subheading": "Подзаголовок",
|
||||
"subtract": "Вычесть -",
|
||||
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
|
||||
"survey_completed_heading": "Опрос завершён",
|
||||
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
|
||||
"survey_display_settings": "Настройки отображения опроса",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "Styling inställd på temastil",
|
||||
"subheading": "Underrubrik",
|
||||
"subtract": "Subtrahera -",
|
||||
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
|
||||
"survey_completed_heading": "Enkät slutförd",
|
||||
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
|
||||
"survey_display_settings": "Visningsinställningar för enkät",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "样式 设置 为 主题 风格",
|
||||
"subheading": "子标题",
|
||||
"subtract": "减 -",
|
||||
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
|
||||
"survey_completed_heading": "调查 完成",
|
||||
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
|
||||
"survey_display_settings": "调查显示设置",
|
||||
|
||||
@@ -1698,7 +1698,6 @@
|
||||
"styling_set_to_theme_styles": "樣式設定為主題樣式",
|
||||
"subheading": "副標題",
|
||||
"subtract": "減 -",
|
||||
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
|
||||
"survey_completed_heading": "問卷已完成",
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details with method and path", () => {
|
||||
test("logs API error details", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -228,7 +228,7 @@ describe("utils", () => {
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -238,11 +238,9 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "POST",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -277,8 +275,6 @@ describe("utils", () => {
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "",
|
||||
method: "GET",
|
||||
path: "/api/test",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -289,7 +285,7 @@ describe("utils", () => {
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
|
||||
test("log API error details with SENTRY_DSN set", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -299,23 +295,11 @@ describe("utils", () => {
|
||||
// Mock Sentry's captureException method
|
||||
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
|
||||
|
||||
// Capture the scope mock for tag verification
|
||||
const scopeSetTagMock = vi.fn();
|
||||
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: scopeSetTagMock,
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -325,60 +309,20 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "DELETE",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Verify Sentry scope tags include method and path
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("does not send to Sentry for non-internal_server_error types", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
});
|
||||
|
||||
vi.mocked(Sentry.captureException).mockClear();
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
|
||||
mockRequest.headers.set("x-request-id", "456");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify Sentry.captureException was NOT called for non-500 errors
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// But structured logging should still happen
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,13 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
|
||||
// Use Sentry scope to add correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setTag("method", method);
|
||||
scope.setTag("path", path);
|
||||
scope.setLevel("error");
|
||||
|
||||
scope.setExtra("originalError", error);
|
||||
@@ -29,8 +24,6 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
logger
|
||||
.withContext({
|
||||
correlationId,
|
||||
method,
|
||||
path,
|
||||
error,
|
||||
})
|
||||
.error("API V2 Error Details");
|
||||
|
||||
@@ -97,13 +97,14 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
);
|
||||
|
||||
const ZUpdateSegmentAction = z.object({
|
||||
environmentId: ZId,
|
||||
segmentId: ZId,
|
||||
data: ZSegmentUpdateInput,
|
||||
});
|
||||
|
||||
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
|
||||
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
|
||||
@@ -75,6 +75,7 @@ export function SegmentSettings({
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
const data = await updateSegmentAction({
|
||||
environmentId,
|
||||
segmentId: segment.id,
|
||||
data: {
|
||||
title: segment.title,
|
||||
|
||||
@@ -124,7 +124,7 @@ export function TargetingCard({
|
||||
};
|
||||
|
||||
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, data });
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
|
||||
return updatedSegment?.data as TSegment;
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ export function TargetingCard({
|
||||
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
|
||||
try {
|
||||
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, data });
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZDeleteQuotaAction = z.object({
|
||||
quotaId: ZId,
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
const checkQuotasEnabled = async (organizationId: string) => {
|
||||
@@ -36,7 +37,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
|
||||
|
||||
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
|
||||
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -48,7 +49,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -71,7 +72,7 @@ const ZUpdateQuotaAction = z.object({
|
||||
|
||||
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
|
||||
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -83,7 +84,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,6 +85,7 @@ export const QuotasCard = ({
|
||||
setIsDeletingQuota(true);
|
||||
const deleteQuotaActionResult = await deleteQuotaAction({
|
||||
quotaId: quotaId,
|
||||
surveyId: localSurvey.id,
|
||||
});
|
||||
if (deleteQuotaActionResult?.data) {
|
||||
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
|
||||
@@ -32,6 +31,7 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
|
||||
|
||||
const ZUpdateInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
data: ZInviteUpdateInput,
|
||||
});
|
||||
|
||||
@@ -39,16 +39,17 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
|
||||
|
||||
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
|
||||
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
ctx.user.id,
|
||||
parsedInput.organizationId
|
||||
);
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
@@ -67,9 +68,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
|
||||
throw new OperationNotAllowedError("Managers can only invite members");
|
||||
}
|
||||
|
||||
await checkRoleManagementPermission(organizationId);
|
||||
await checkRoleManagementPermission(parsedInput.organizationId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function EditMembershipRole({
|
||||
}
|
||||
|
||||
if (inviteId) {
|
||||
await updateInviteAction({ inviteId: inviteId, data: { role } });
|
||||
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
|
||||
@@ -27,15 +27,14 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
|
||||
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -43,7 +42,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
return await deleteInvite(parsedInput.inviteId);
|
||||
|
||||
@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
if (!member && invite) {
|
||||
// This is an invite
|
||||
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id });
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
|
||||
@@ -316,6 +316,10 @@ describe("validation.isEndingCardValid", () => {
|
||||
const card = { ...baseRedirectUrlCard, label: " " };
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
// test("should return false for redirectUrl card if label is undefined", () => {
|
||||
// const card = { ...baseRedirectUrlCard, label: undefined };
|
||||
// expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
// });
|
||||
});
|
||||
|
||||
describe("validation.validateElement", () => {
|
||||
@@ -1025,66 +1029,6 @@ describe("validation.isSurveyValid", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return false and toast error if a link survey has an empty custom survey closed message heading", () => {
|
||||
const surveyWithEmptyClosedMessageHeading = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: "",
|
||||
subheading: "Closed for now",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, "en", mockT)).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"environments.surveys.edit.survey_closed_message_heading_required"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return false and toast error if a link survey has a whitespace-only custom survey closed message heading", () => {
|
||||
const surveyWithWhitespaceClosedMessageHeading = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: " ",
|
||||
subheading: "",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, "en", mockT)).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"environments.surveys.edit.survey_closed_message_heading_required"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true if a link survey has a custom survey closed message heading and no subheading", () => {
|
||||
const surveyWithHeadingOnlyClosedMessage = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: "Survey closed",
|
||||
subheading: "",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, "en", mockT)).toBe(true);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return true if a link survey has a custom survey closed message heading and subheading", () => {
|
||||
const surveyWithClosedMessageContent = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: "Survey closed",
|
||||
subheading: "Thanks for your interest",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithClosedMessageContent, "en", mockT)).toBe(true);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("App Survey Segment Validation", () => {
|
||||
test("should return false and toast error for app survey with invalid segment filters", () => {
|
||||
const surveyWithInvalidSegment = {
|
||||
|
||||
@@ -151,7 +151,11 @@ export const validationRules = {
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field];
|
||||
if (fieldValue?.[defaultLanguageCode] !== undefined && fieldValue[defaultLanguageCode].trim() !== "") {
|
||||
if (
|
||||
fieldValue &&
|
||||
typeof fieldValue[defaultLanguageCode] !== "undefined" &&
|
||||
fieldValue[defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages);
|
||||
}
|
||||
}
|
||||
@@ -199,16 +203,6 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
|
||||
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
|
||||
};
|
||||
|
||||
const hasValidSurveyClosedMessageHeading = (survey: TSurvey): boolean => {
|
||||
if (survey.type !== "link" || !survey.surveyClosedMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const heading = survey.surveyClosedMessage.heading?.trim() ?? "";
|
||||
|
||||
return heading.length > 0;
|
||||
};
|
||||
|
||||
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
|
||||
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages);
|
||||
};
|
||||
@@ -292,10 +286,5 @@ export const isSurveyValid = (
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidSurveyClosedMessageHeading(survey)) {
|
||||
toast.error(t("environments.surveys.edit.survey_closed_message_heading_required"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -163,6 +163,9 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
}
|
||||
} else {
|
||||
setIsMultiLanguageActivated(true);
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -43,17 +43,6 @@ vi.mock("@/lib/common/utils", () => ({
|
||||
handleHiddenFields: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateQueue = {
|
||||
hasPendingWork: vi.fn().mockReturnValue(false),
|
||||
waitForPendingWork: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/user/update-queue", () => ({
|
||||
UpdateQueue: {
|
||||
getInstance: vi.fn(() => mockUpdateQueue),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("widget-file", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => Config>;
|
||||
let getInstanceLoggerMock: MockInstance<() => Logger>;
|
||||
@@ -260,265 +249,4 @@ describe("widget-file", () => {
|
||||
widget.removeWidgetContainer();
|
||||
expect(document.getElementById("formbricks-container")).toBeFalsy();
|
||||
});
|
||||
|
||||
test("renderWidget waits for pending identification before rendering", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
|
||||
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget does not wait when no identification is pending", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
|
||||
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget reads contactId after identification wait completes", async () => {
|
||||
let callCount = 0;
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
return {
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
// Simulate contactId becoming available after identification
|
||||
userId: "user_abc",
|
||||
contactId: callCount > 2 ? "contact_after_identification" : undefined,
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// The contactId passed to renderSurvey should be read after the wait
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_after_identification",
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"User identification failed. Skipping survey with segment filters."
|
||||
);
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: null,
|
||||
contactId: null,
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
segment: undefined,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"User identification failed but survey has no segment filters. Proceeding."
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
handleHiddenFields,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
} from "@/lib/common/utils";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
||||
import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
@@ -61,24 +60,6 @@ export const renderWidget = async (
|
||||
|
||||
setIsSurveyRunning(true);
|
||||
|
||||
// Wait for pending user identification to complete before rendering
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
if (updateQueue.hasPendingWork()) {
|
||||
logger.debug("Waiting for pending user identification before rendering survey");
|
||||
const identificationSucceeded = await updateQueue.waitForPendingWork();
|
||||
if (!identificationSucceeded) {
|
||||
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
|
||||
|
||||
if (hasSegmentFilters) {
|
||||
logger.debug("User identification failed. Skipping survey with segment filters.");
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.delay) {
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
|
||||
}
|
||||
|
||||
@@ -169,104 +169,4 @@ describe("UpdateQueue", () => {
|
||||
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function"
|
||||
);
|
||||
});
|
||||
|
||||
test("hasPendingWork returns false when no updates and no flush in flight", () => {
|
||||
expect(updateQueue.hasPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test("hasPendingWork returns true when updates are queued", () => {
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
expect(updateQueue.hasPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test("hasPendingWork returns true while processUpdates flush is in flight", () => {
|
||||
(sendUpdates as Mock).mockReturnValue({
|
||||
ok: true,
|
||||
data: { hasWarnings: false },
|
||||
});
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
// Start processing but don't await — the debounce means the flush is in-flight
|
||||
void updateQueue.processUpdates();
|
||||
|
||||
expect(updateQueue.hasPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns true immediately when no pending work", async () => {
|
||||
const result = await updateQueue.waitForPendingWork();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns true when processUpdates succeeds", async () => {
|
||||
(sendUpdates as Mock).mockReturnValue({
|
||||
ok: true,
|
||||
data: { hasWarnings: false },
|
||||
});
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
void updateQueue.processUpdates();
|
||||
|
||||
const result = await updateQueue.waitForPendingWork();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(updateQueue.hasPendingWork()).toBe(false);
|
||||
expect(sendUpdates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns false when processUpdates rejects", async () => {
|
||||
loggerMock.mockReturnValue(mockLogger as unknown as Logger);
|
||||
(sendUpdates as Mock).mockRejectedValue(new Error("network error"));
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallowing rejection to avoid unhandled promise
|
||||
const processPromise = updateQueue.processUpdates().catch(() => {});
|
||||
|
||||
const result = await updateQueue.waitForPendingWork();
|
||||
expect(result).toBe(false);
|
||||
await processPromise;
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns false when flush hangs past timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// sendUpdates returns a promise that never resolves, simulating a network hang
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
|
||||
(sendUpdates as Mock).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
void updateQueue.processUpdates();
|
||||
|
||||
const resultPromise = updateQueue.waitForPendingWork();
|
||||
|
||||
// Advance past the debounce delay (500ms) so the handler fires and hangs on sendUpdates
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
// Advance past the pending work timeout (5000ms)
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("processUpdates reuses pending flush instead of creating orphaned promises", async () => {
|
||||
(sendUpdates as Mock).mockReturnValue({
|
||||
ok: true,
|
||||
data: { hasWarnings: false },
|
||||
});
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
|
||||
// First call creates the flush promise
|
||||
const firstPromise = updateQueue.processUpdates();
|
||||
|
||||
// Second call while first is still pending should not create a new flush
|
||||
updateQueue.updateAttributes({ name: mockAttributes.name });
|
||||
const secondPromise = updateQueue.processUpdates();
|
||||
|
||||
// Both promises should resolve (second is not orphaned)
|
||||
await Promise.all([firstPromise, secondPromise]);
|
||||
|
||||
expect(updateQueue.hasPendingWork()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,7 @@ export class UpdateQueue {
|
||||
private static instance: UpdateQueue | null = null;
|
||||
private updates: TUpdates | null = null;
|
||||
private debounceTimeout: NodeJS.Timeout | null = null;
|
||||
private pendingFlush: Promise<void> | null = null;
|
||||
private readonly DEBOUNCE_DELAY = 500;
|
||||
private readonly PENDING_WORK_TIMEOUT = 5000;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -65,45 +63,17 @@ export class UpdateQueue {
|
||||
return !this.updates;
|
||||
}
|
||||
|
||||
public hasPendingWork(): boolean {
|
||||
return this.updates !== null || this.pendingFlush !== null;
|
||||
}
|
||||
|
||||
public async waitForPendingWork(): Promise<boolean> {
|
||||
if (!this.hasPendingWork()) return true;
|
||||
|
||||
const flush = this.pendingFlush ?? this.processUpdates();
|
||||
try {
|
||||
const succeeded = await Promise.race([
|
||||
flush.then(() => true as const),
|
||||
new Promise<false>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
}, this.PENDING_WORK_TIMEOUT);
|
||||
}),
|
||||
]);
|
||||
return succeeded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async processUpdates(): Promise<void> {
|
||||
const logger = Logger.getInstance();
|
||||
if (!this.updates) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a flush is already in flight, reuse it instead of creating a new promise
|
||||
if (this.pendingFlush) {
|
||||
return this.pendingFlush;
|
||||
}
|
||||
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
|
||||
const flushPromise = new Promise<void>((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = async (): Promise<void> => {
|
||||
try {
|
||||
let currentUpdates = { ...this.updates };
|
||||
@@ -177,10 +147,8 @@ export class UpdateQueue {
|
||||
}
|
||||
|
||||
this.clearUpdates();
|
||||
this.pendingFlush = null;
|
||||
resolve();
|
||||
} catch (error: unknown) {
|
||||
this.pendingFlush = null;
|
||||
logger.error(
|
||||
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
@@ -190,8 +158,5 @@ export class UpdateQueue {
|
||||
|
||||
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
|
||||
});
|
||||
|
||||
this.pendingFlush = flushPromise;
|
||||
return flushPromise;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user