import { WidgetValidationService } from "../services/WidgetValidationService"; import type { WidgetStateManager } from "../services/WidgetStateManager"; import type { WidgetOptions, WidgetSamplerLog } from "../domain"; describe("WidgetValidationService", () => { let service: WidgetValidationService; let mockStateManager: jest.Mocked; const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; const MOCK_NOW = 1678200000000; beforeEach(() => { mockStateManager = { getLogs: jest.fn(), saveLogs: jest.fn(), incrementAttempt: jest.fn(), resetAttempts: jest.fn(), markTransactionAnswered: jest.fn(), hasAnsweredTransaction: jest.fn(), clearLogs: jest.fn(), hasLogs: jest.fn(), updateTimestamp: jest.fn(), overrideTimestamp: jest.fn(), } as unknown as jest.Mocked; service = new WidgetValidationService(mockStateManager); }); const defaultLog = (): WidgetSamplerLog => ({ attempts: 0, answeredTransactionIds: [], lastFirstAccess: Date.now() }); describe("shouldDisplayForTransaction", () => { it("should allow display when transaction ID is not provided", async () => { const result = await service.shouldDisplayForTransactionAlreadyAnswered(); expect(result).toEqual({ canDisplay: true }); expect(mockStateManager.hasAnsweredTransaction).not.toHaveBeenCalled(); }); it("should block when transaction has already been answered", async () => { mockStateManager.hasAnsweredTransaction.mockResolvedValue(true); const result = await service.shouldDisplayForTransactionAlreadyAnswered("txn-123"); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_TRANSACTION_ALREADY_ANSWERED" }); }); it("should allow when transaction has not been answered", async () => { mockStateManager.hasAnsweredTransaction.mockResolvedValue(false); const result = await service.shouldDisplayForTransactionAlreadyAnswered("txn-123"); expect(result).toEqual({ canDisplay: true }); }); }); describe("shouldDisplayWidget", () => { it("should allow display when user has no previous logs", async () => { mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); }); it("should allow display when no wait days are configured", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS }); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); }); it("should allow display when all wait days fields are 0", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS, lastSubmit: MOCK_NOW - ONE_DAY_IN_MS, lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 0, waitDaysAfterWidgetFirstAccess: 0, waitDaysAfterWidgetDisplay: 0, waitDaysAfterWidgetDismiss: 0, waitDaysAfterWidgetSubmit: 0, waitDaysAfterWidgetPartialSubmit: 0 }); expect(result).toEqual({ canDisplay: true }); }); it("should use default logs when getLogs throws error", async () => { const spy = jest.spyOn(console, "error").mockImplementation(() => {}); mockStateManager.getLogs.mockRejectedValue(new Error("Storage unavailable")); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); expect(spy).toHaveBeenCalledWith("Error reading widget log:", expect.any(Error)); spy.mockRestore(); }); it("should handle empty widget options", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); }); }); describe("waitDaysAfterWidgetDisplayAttempt", () => { it("should block when within interval", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 2 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISPLAY_ATTEMPT_INTERVAL" }); }); it("should allow when interval expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 8 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7 }); expect(result).toEqual({ canDisplay: true }); }); }); describe("waitDaysAfterWidgetFirstAccess", () => { it("should block on first evaluation when wait days is configured and no there is no log for first access", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({... defaultLog(), lastFirstAccess: undefined }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetFirstAccess: 30 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_FIRST_ACCESS_INTERVAL" }); expect(mockStateManager.saveLogs).toHaveBeenCalledWith({ ...defaultLog(), lastFirstAccess: MOCK_NOW }); }); it("should not block when wait days is not configured and no there is no log for first access", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({... defaultLog(), lastFirstAccess: undefined }); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); expect(mockStateManager.saveLogs).toHaveBeenCalledWith({ ...defaultLog(), lastFirstAccess: MOCK_NOW }); }); it("should not initialize first access when there is log for first access", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetFirstAccess: 0 }); expect(result).toEqual({ canDisplay: true }); expect(mockStateManager.saveLogs).not.toHaveBeenCalled(); }); it("should initialize first access when there is no log for first access", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({... defaultLog(), lastFirstAccess: undefined }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetFirstAccess: 0 }); expect(result).toEqual({ canDisplay: true }); expect(mockStateManager.saveLogs).toHaveBeenCalledWith({ ...defaultLog(), lastFirstAccess: MOCK_NOW }); }); it("should block when within interval", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 10 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetFirstAccess: 30 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_FIRST_ACCESS_INTERVAL" }); }); it("should allow when interval expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 31 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetFirstAccess: 30 }); expect(result).toEqual({ canDisplay: true }); }); }); describe("waitDaysAfterWidgetDisplay", () => { it("should block when within interval", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 3 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplay: 7 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISPLAY_INTERVAL" }); }); it("should allow when interval expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 8 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplay: 7 }); expect(result).toEqual({ canDisplay: true }); }); }); describe("waitDaysAfterWidgetDismiss", () => { it("should block when within interval", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 30 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISMISS_INTERVAL" }); }); it("should allow when interval expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 91 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 }); expect(result).toEqual({ canDisplay: true }); }); }); describe("waitDaysAfterWidgetSubmit", () => { it("should block when within interval", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 15 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetSubmit: 30 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_SUBMIT_INTERVAL" }); }); it("should allow when interval expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 31 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetSubmit: 30 }); expect(result).toEqual({ canDisplay: true }); }); }); describe("waitDaysAfterWidgetPartialSubmit", () => { it("should block when within interval", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 10 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetPartialSubmit: 30 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_PARTIAL_SUBMIT_INTERVAL" }); }); it("should allow when interval expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 31 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetPartialSubmit: 30 }); expect(result).toEqual({ canDisplay: true }); }); }); describe("multiple rules and edge cases", () => { it("should block by first matching rule", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 2, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 2 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7, waitDaysAfterWidgetFirstAccess: 7 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISPLAY_ATTEMPT_INTERVAL" }); }); it("should block when any rule blocks", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 100, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 100, lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 5, lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 100 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 1, waitDaysAfterWidgetFirstAccess: 1, waitDaysAfterWidgetDisplay: 1, waitDaysAfterWidgetDismiss: 1, waitDaysAfterWidgetSubmit: 30, waitDaysAfterWidgetPartialSubmit: 1 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_SUBMIT_INTERVAL" }); }); it("should handle old logs without new timestamp fields", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90, waitDaysAfterWidgetSubmit: 60 }); expect(result).toEqual({ canDisplay: true }); }); it("should not block when timestamp is 0", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: 0 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 }); expect(result).toEqual({ canDisplay: true }); }); it("should allow at exact boundary", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 30 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 30 }); expect(result).toEqual({ canDisplay: true }); }); it("should block one millisecond before boundary", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - (ONE_DAY_IN_MS * 30 - 1) }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 30 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISMISS_INTERVAL" }); }); it("should handle negative timestamp values", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ attempts: 0, lastDismiss: -1000 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 }); expect(result).toEqual({ canDisplay: true }); }); it("should allow when all intervals expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 100, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 100, lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 100, lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 100 }); const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7, waitDaysAfterWidgetFirstAccess: 30, waitDaysAfterWidgetDisplay: 7, waitDaysAfterWidgetDismiss: 90, waitDaysAfterWidgetSubmit: 30, waitDaysAfterWidgetPartialSubmit: 60 }); expect(result).toEqual({ canDisplay: true }); }); it("should allow with height-only options", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS }); const result = await service.shouldDisplayWidget({ height: 600 }); expect(result).toEqual({ canDisplay: true }); }); }); describe("maxAttemptsAfterDismiss", () => { it("should block when attempts >= maxAttemptsAfterDismiss", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5 }); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" }); }); it("should block when attempts exceed maxAttemptsAfterDismiss", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 10 }); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" }); }); it("should allow when attempts < maxAttemptsAfterDismiss", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 3 }); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }); expect(result).toEqual({ canDisplay: true }); }); it("should allow when maxAttemptsAfterDismiss is 0 (no limit)", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 9999 }); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 0 }); expect(result).toEqual({ canDisplay: true }); }); it("should allow when maxAttemptsAfterDismiss is not set", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 9999 }); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); }); it("should check maxAttemptsAfterDismiss before interval rules", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 2 }); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5, waitDaysAfterWidgetDismiss: 90 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" }); }); }); describe("sampling percentage", () => { it("should block when random value exceeds sampling percentage", async () => { jest.spyOn(Math, "random").mockReturnValue(0.8); // 80 >= 50 → blocked mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ samplingPercentage: 50 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_SAMPLING" }); }); it("should allow when random value is below sampling percentage", async () => { jest.spyOn(Math, "random").mockReturnValue(0.3); // 30 < 50 → allowed mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ samplingPercentage: 50 }); expect(result).toEqual({ canDisplay: true }); }); it("should skip sampling when percentage is 100 (show all)", async () => { jest.spyOn(Math, "random").mockReturnValue(0.99); mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ samplingPercentage: 100 }); expect(result).toEqual({ canDisplay: true }); }); it("should skip sampling when percentage is undefined", async () => { jest.spyOn(Math, "random").mockReturnValue(0.99); mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); }); it("should block all when sampling percentage is 0", async () => { jest.spyOn(Math, "random").mockReturnValue(0.01); mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ samplingPercentage: 0 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_SAMPLING" }); }); it("should check sampling after enabled check", async () => { jest.spyOn(Math, "random").mockReturnValue(0.8); mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ enabled: false, samplingPercentage: 50 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_DISABLED" }); }); }); describe("enabled/disabled", () => { it("should block immediately when enabled is false without checking other rules", async () => { mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ enabled: false, waitDaysAfterWidgetDismiss: 0 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_DISABLED" }); expect(mockStateManager.getLogs).not.toHaveBeenCalled(); }); it("should allow display when enabled is true", async () => { mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({ enabled: true }); expect(result).toEqual({ canDisplay: true }); }); it("should allow display when enabled is undefined (default)", async () => { mockStateManager.getLogs.mockResolvedValue(defaultLog()); const result = await service.shouldDisplayWidget({}); expect(result).toEqual({ canDisplay: true }); }); it("should block by disabled even if all intervals are expired", async () => { jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW); mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 100 }); const result = await service.shouldDisplayWidget({ enabled: false, waitDaysAfterWidgetDismiss: 1 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_DISABLED" }); }); }); describe("experience ID reset", () => { it("should reset attempts when experience ID changes", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastExperienceId: "old_journey" }); mockStateManager.saveLogs.mockResolvedValue(undefined); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }, "new_journey"); expect(result).toEqual({ canDisplay: true }); expect(mockStateManager.saveLogs).toHaveBeenCalledWith(expect.objectContaining({ attempts: 0, lastExperienceId: "new_journey", })); }); it("should not reset attempts when experience ID is the same", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastExperienceId: "same_journey" }); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }, "same_journey"); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" }); expect(mockStateManager.saveLogs).not.toHaveBeenCalled(); }); it("should save experience ID on first call when not set", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 0 }); mockStateManager.saveLogs.mockResolvedValue(undefined); const result = await service.shouldDisplayWidget({}, "first_journey"); expect(result).toEqual({ canDisplay: true }); expect(mockStateManager.saveLogs).toHaveBeenCalledWith(expect.objectContaining({ lastExperienceId: "first_journey", })); }); it("should not reset attempts when no experience ID is provided", async () => { mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastExperienceId: "some_journey" }); const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }); expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" }); expect(mockStateManager.saveLogs).not.toHaveBeenCalled(); }); it("should preserve timestamp fields when resetting attempts", async () => { const log = { ...defaultLog(), attempts: 5, lastExperienceId: "old_journey", lastDismiss: 12345, lastSubmit: 67890 }; mockStateManager.getLogs.mockResolvedValue(log); mockStateManager.saveLogs.mockResolvedValue(undefined); await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }, "new_journey"); expect(mockStateManager.saveLogs).toHaveBeenCalledWith(expect.objectContaining({ attempts: 0, lastExperienceId: "new_journey", lastDismiss: 12345, lastSubmit: 67890, })); }); }); });