diff --git a/app/account/tabs/__tests__/delete-tab-premium.test.tsx b/app/account/tabs/__tests__/delete-tab-premium.test.tsx index 6b202bb..5fbafaf 100644 --- a/app/account/tabs/__tests__/delete-tab-premium.test.tsx +++ b/app/account/tabs/__tests__/delete-tab-premium.test.tsx @@ -18,6 +18,7 @@ import { waitFor, } from "@testing-library/react-native"; import React from "react"; +import { Alert } from "react-native"; import { useCydAccount } from "@/contexts/CydAccountProvider"; @@ -241,6 +242,7 @@ describe("DeleteTab Premium Integration", () => { beforeEach(() => { jest.clearAllMocks(); + mockCheckPremiumAccess.mockResolvedValue({ status: "signed_out" }); mockWithBlueskyController.mockImplementation( async (_accountId, _accountUUID, fn) => { return fn({ @@ -370,6 +372,7 @@ describe("DeleteTab Premium Integration", () => { }); it("should show PremiumRequiredModal when signed in without premium", async () => { + mockCheckPremiumAccess.mockResolvedValue({ status: "not_premium" }); mockUseCydAccount.mockReturnValue({ state: { isSignedIn: true, @@ -476,6 +479,7 @@ describe("DeleteTab Premium Integration", () => { }); // Confirm premium + mockCheckPremiumAccess.mockResolvedValue({ status: "premium" }); await act(async () => { fireEvent.press(screen.getByTestId("premium-modal-confirm")); }); @@ -490,6 +494,7 @@ describe("DeleteTab Premium Integration", () => { describe("Delete My Data button - with premium", () => { beforeEach(() => { + mockCheckPremiumAccess.mockResolvedValue({ status: "premium" }); mockUseCydAccount.mockReturnValue({ state: { isSignedIn: true, @@ -561,6 +566,25 @@ describe("DeleteTab Premium Integration", () => { expect(screen.queryByTestId("premium-required-modal")).toBeNull(); }); + + it("blocks deletion when cached Premium access has expired", async () => { + mockCheckPremiumAccess.mockResolvedValue({ status: "not_premium" }); + render(); + + await waitFor(() => { + expect(screen.getByText("Continue to Review")).toBeTruthy(); + }); + fireEvent.press(screen.getByText("Continue to Review")); + await waitFor(() => expect(screen.getByText("Delete My Data")).toBeTruthy()); + + fireEvent.press(screen.getByText("Delete My Data")); + + await waitFor(() => { + expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("premium-required-modal")).toBeTruthy(); + }); + expect(screen.queryByTestId("delete-automation-modal")).toBeNull(); + }); }); describe("Continue to Review button", () => { @@ -585,7 +609,12 @@ describe("DeleteTab Premium Integration", () => { }); describe("premium check error handling", () => { - it("should show PremiumRequiredModal when premium check fails with error", async () => { + it("should show a retryable error when premium verification fails", async () => { + const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(); + mockCheckPremiumAccess.mockResolvedValue({ + status: "error", + message: "Server error", + }); mockUseCydAccount.mockReturnValue({ state: { isSignedIn: true, @@ -618,13 +647,19 @@ describe("DeleteTab Premium Integration", () => { fireEvent.press(screen.getByText("Delete My Data")); }); - // Should show premium modal (since hasPremiumAccess is false) await waitFor(() => { - expect(screen.getByTestId("premium-required-modal")).toBeTruthy(); + expect(alertSpy).toHaveBeenCalledWith( + "Couldn’t verify Premium", + "Check your connection and try again.", + expect.any(Array), + ); }); + expect(screen.queryByTestId("premium-required-modal")).toBeNull(); }); - it("should show PremiumRequiredModal when premium check throws", async () => { + it("fails closed when the premium check throws", async () => { + const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(); + mockCheckPremiumAccess.mockRejectedValue(new Error("Network error")); mockUseCydAccount.mockReturnValue({ state: { isSignedIn: true, @@ -657,10 +692,10 @@ describe("DeleteTab Premium Integration", () => { fireEvent.press(screen.getByText("Delete My Data")); }); - // Should show premium modal (since hasPremiumAccess is false) await waitFor(() => { - expect(screen.getByTestId("premium-required-modal")).toBeTruthy(); + expect(alertSpy).toHaveBeenCalled(); }); + expect(screen.queryByTestId("delete-automation-modal")).toBeNull(); }); }); }); diff --git a/app/account/tabs/__tests__/schedule-tab-premium.test.tsx b/app/account/tabs/__tests__/schedule-tab-premium.test.tsx new file mode 100644 index 0000000..df7456b --- /dev/null +++ b/app/account/tabs/__tests__/schedule-tab-premium.test.tsx @@ -0,0 +1,289 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react-native"; +import React from "react"; + +import { Colors } from "@/constants/theme"; +import { useCydAccount } from "@/contexts/CydAccountProvider"; +import { getAccountDeleteSettings } from "@/database/delete-settings"; + +import { ScheduleTab } from "../schedule-tab"; + +const mockCheckPremiumAccess = jest.fn(); + +jest.mock("expo-router", () => ({ + useLocalSearchParams: () => ({ scheduleShowReview: "true" }), +})); +jest.mock("@react-native-community/datetimepicker", () => () => null); +jest.mock("expo-localization", () => ({ + getCalendars: () => [{ timeZone: "UTC" }], +})); +jest.mock("@/services/push-notifications", () => ({ + registerForPushNotifications: jest.fn(), +})); + +jest.mock("@/contexts/CydAccountProvider", () => ({ + useCydAccount: jest.fn(), +})); + +jest.mock("@/database/accounts", () => ({ + getAccountHandle: jest.fn().mockResolvedValue("alice.test"), + getLastDeletedAt: jest.fn().mockResolvedValue(Date.now()), + getLastSavedAt: jest.fn().mockResolvedValue(Date.now()), + setLastDeletedAt: jest.fn().mockResolvedValue(undefined), + setLastSavedAt: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("@/database/schedule-settings", () => ({ + getAccountScheduleSettings: jest.fn().mockResolvedValue({ + scheduleDeletion: true, + scheduleDeletionFrequency: "weekly", + scheduleDeletionDayOfMonth: 1, + scheduleDeletionDayOfWeek: 0, + scheduleDeletionTime: "09:00", + }), + updateAccountScheduleSettings: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("@/database/save-settings", () => ({ + getAccountSaveSettings: jest.fn().mockResolvedValue({ + posts: true, + likes: true, + bookmarks: true, + chat: true, + }), +})); + +jest.mock("@/database/delete-settings", () => ({ + getAccountDeleteSettings: jest.fn(), +})); + +jest.mock("@/app/account/components/SaveReviewList", () => ({ + SaveReviewList: () => null, +})); +jest.mock("@/app/account/components/DeleteReviewList", () => ({ + DeleteReviewList: () => null, +})); +jest.mock("@/components/PremiumRequiredBanner", () => ({ + PremiumRequiredBanner: () => null, +})); +jest.mock("@/components/PremiumRequiredModal", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const { Pressable, Text } = require("react-native"); + return { + PremiumRequiredModal: ({ + visible, + onPremiumConfirmed, + }: { + visible: boolean; + onPremiumConfirmed: () => void; + }) => + visible ? ( + + Premium required + + ) : null, + }; +}); +jest.mock("@/components/SaveAndDeleteStatusBanner", () => ({ + SaveAndDeleteStatusBanner: () => null, +})); +jest.mock("@/components/LastActionTimestamp", () => ({ + LastActionTimestamp: () => null, +})); +jest.mock("@/app/account/components/FinishedModal", () => ({ + FinishedModal: () => null, +})); +jest.mock("@/app/account/components/ScheduledAutomationModal", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const { Text } = require("react-native"); + return { + ScheduledAutomationModal: ({ visible }: { visible: boolean }) => + visible ? Automation running : null, + }; +}); + +const mockUseCydAccount = useCydAccount as jest.Mock; +const mockGetAccountDeleteSettings = getAccountDeleteSettings as jest.Mock; + +const noDeletes = { + deletePosts: false, + deletePostsDaysOldEnabled: false, + deletePostsDaysOld: 0, + deletePostsLikesThresholdEnabled: false, + deletePostsLikesThreshold: 0, + deletePostsRepostsThresholdEnabled: false, + deletePostsRepostsThreshold: 0, + deletePostsPreserveThreads: false, + deleteReposts: false, + deleteRepostsDaysOldEnabled: false, + deleteRepostsDaysOld: 0, + deleteLikes: false, + deleteLikesDaysOldEnabled: false, + deleteLikesDaysOld: 0, + deleteBookmarks: false, + deleteChats: false, + deleteChatsDaysOldEnabled: false, + deleteChatsDaysOld: 0, + deleteUnfollowEveryone: false, +}; + +describe("ScheduleTab Premium execution gate", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseCydAccount.mockReturnValue({ + state: { + isSignedIn: true, + userEmail: "alice@example.com", + isLoading: false, + hasPremiumAccess: true, + }, + apiClient: {}, + checkPremiumAccess: mockCheckPremiumAccess, + }); + }); + + it("blocks a notification-launched run when cached Premium has expired", async () => { + mockGetAccountDeleteSettings.mockResolvedValue({ + ...noDeletes, + deletePosts: true, + }); + mockCheckPremiumAccess.mockResolvedValue({ status: "not_premium" }); + + render( + , + ); + + await waitFor(() => + expect( + screen.getByText("Ready to save and delete your Bluesky data?"), + ).toBeTruthy(), + ); + fireEvent.press(screen.getByText("Save and Delete Data Now")); + + await waitFor(() => expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId("premium-required-modal")).toBeTruthy(); + expect(screen.queryByTestId("scheduled-automation")).toBeNull(); + }); + + it("starts a free-only run without checking Premium or requiring Cyd sign-in", async () => { + mockGetAccountDeleteSettings.mockResolvedValue(noDeletes); + mockUseCydAccount.mockReturnValue({ + state: { + isSignedIn: false, + userEmail: null, + isLoading: false, + hasPremiumAccess: null, + }, + apiClient: {}, + checkPremiumAccess: mockCheckPremiumAccess, + }); + + render( + , + ); + + await waitFor(() => + expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(), + ); + fireEvent.press(screen.getByText("Save and Delete Data Now")); + + await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy()); + expect(mockCheckPremiumAccess).not.toHaveBeenCalled(); + }); + + it("starts a Premium run only after a fresh successful check", async () => { + mockGetAccountDeleteSettings.mockResolvedValue({ + ...noDeletes, + deleteLikes: true, + }); + mockCheckPremiumAccess.mockResolvedValue({ status: "premium" }); + + render( + , + ); + await waitFor(() => + expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(), + ); + fireEvent.press(screen.getByText("Save and Delete Data Now")); + + await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy()); + expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1); + }); + + it("re-verifies and resumes the retained run after Premium is confirmed", async () => { + mockGetAccountDeleteSettings.mockResolvedValue({ + ...noDeletes, + deleteBookmarks: true, + }); + mockCheckPremiumAccess + .mockResolvedValueOnce({ status: "not_premium" }) + .mockResolvedValueOnce({ status: "premium" }); + + render( + , + ); + await waitFor(() => + expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(), + ); + fireEvent.press(screen.getByText("Save and Delete Data Now")); + await waitFor(() => + expect(screen.getByTestId("premium-required-modal")).toBeTruthy(), + ); + + fireEvent.press(screen.getByTestId("premium-required-modal")); + + await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy()); + expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(2); + }); + + it("coalesces repeated taps while verification is in flight", async () => { + mockGetAccountDeleteSettings.mockResolvedValue({ + ...noDeletes, + deleteChats: true, + }); + let resolveCheck!: (value: { status: "premium" }) => void; + mockCheckPremiumAccess.mockReturnValue( + new Promise((resolve) => { + resolveCheck = resolve; + }), + ); + + render( + , + ); + await waitFor(() => + expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(), + ); + const startButton = screen.getByText("Save and Delete Data Now"); + fireEvent.press(startButton); + fireEvent.press(startButton); + + expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1); + resolveCheck({ status: "premium" }); + await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy()); + }); +}); diff --git a/app/account/tabs/delete-tab.tsx b/app/account/tabs/delete-tab.tsx index a870cc3..dd40046 100644 --- a/app/account/tabs/delete-tab.tsx +++ b/app/account/tabs/delete-tab.tsx @@ -1,7 +1,8 @@ import { MaterialIcons } from "@expo/vector-icons"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, + Alert, Pressable, ScrollView, Text, @@ -29,6 +30,7 @@ import { useCydAccount } from "@/contexts/CydAccountProvider"; import { withBlueskyController } from "@/controllers"; import type { DeletionPreviewCounts } from "@/controllers/bluesky/deletion-calculator"; import type { BlueskyJobRecord } from "@/controllers/bluesky/job-types"; +import { deleteSettingsRequirePremium } from "@/controllers/bluesky/premium-feature-policy"; import { getLastSavedAt, setLastDeletedAt } from "@/database/accounts"; import { getAccountDeleteSettings, @@ -51,7 +53,7 @@ export function DeleteTab({ palette, onSelectTab, }: AccountTabProps) { - const { apiClient } = useCydAccount(); + const { apiClient, checkPremiumAccess } = useCydAccount(); const [screenStack, setScreenStack] = useState(["form"]); const [state, setState] = useState(null); const [loading, setLoading] = useState(true); @@ -198,20 +200,13 @@ export function DeleteTab({ // Premium modal state const [premiumModalVisible, setPremiumModalVisible] = useState(false); + const [checkingPremium, setCheckingPremium] = useState(false); + const premiumCheckInFlightRef = useRef(false); const [pendingDeleteSettings, setPendingDeleteSettings] = useState(null); const [pendingDeleteCounts, setPendingDeleteCounts] = useState(null); - const handlePremiumRequired = useCallback( - (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => { - setPendingDeleteSettings(settings); - setPendingDeleteCounts(counts); - setPremiumModalVisible(true); - }, - [], - ); - const handlePremiumDismiss = useCallback(() => { setPremiumModalVisible(false); setPendingDeleteSettings(null); @@ -220,27 +215,7 @@ export function DeleteTab({ resetToForm(); }, [resetToForm]); - const handlePremiumConfirmed = useCallback(() => { - setPremiumModalVisible(false); - // Start deletion with the pending settings and counts - if (pendingDeleteSettings && pendingDeleteCounts) { - setAutomationSettings(pendingDeleteSettings); - setAutomationCounts({ - posts: pendingDeleteCounts.posts, - reposts: pendingDeleteCounts.reposts, - likes: pendingDeleteCounts.likes, - bookmarks: pendingDeleteCounts.bookmarks, - messages: pendingDeleteCounts.messages, - follows: pendingDeleteCounts.follows, - }); - setAutomationKey((prev) => prev + 1); - setAutomationVisible(true); - } - setPendingDeleteSettings(null); - setPendingDeleteCounts(null); - }, [pendingDeleteSettings, pendingDeleteCounts]); - - const handleStartDelete = useCallback( + const startDeleteAutomation = useCallback( (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => { setAutomationSettings(settings); setAutomationCounts({ @@ -257,6 +232,89 @@ export function DeleteTab({ [], ); + const verifyAndStartDeleteRef = useRef< + (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => void + >(() => undefined); + + const verifyAndStartDelete = useCallback( + async (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => { + if (premiumCheckInFlightRef.current) return; + + if (!deleteSettingsRequirePremium(settings)) { + startDeleteAutomation(settings, counts); + return; + } + + setPendingDeleteSettings(settings); + setPendingDeleteCounts(counts); + premiumCheckInFlightRef.current = true; + setCheckingPremium(true); + + const showVerificationError = () => { + Alert.alert( + "Couldn’t verify Premium", + "Check your connection and try again.", + [ + { + text: "Cancel", + style: "cancel", + onPress: () => { + setPendingDeleteSettings(null); + setPendingDeleteCounts(null); + }, + }, + { + text: "Retry", + onPress: () => + verifyAndStartDeleteRef.current(settings, counts), + }, + ], + ); + }; + + try { + const result = await checkPremiumAccess(); + if (result.status === "premium") { + setPremiumModalVisible(false); + setPendingDeleteSettings(null); + setPendingDeleteCounts(null); + startDeleteAutomation(settings, counts); + } else if ( + result.status === "not_premium" || + result.status === "signed_out" + ) { + setPremiumModalVisible(true); + } else { + showVerificationError(); + } + } catch { + showVerificationError(); + } finally { + premiumCheckInFlightRef.current = false; + setCheckingPremium(false); + } + }, + [checkPremiumAccess, startDeleteAutomation], + ); + useEffect(() => { + verifyAndStartDeleteRef.current = (settings, counts) => { + void verifyAndStartDelete(settings, counts); + }; + }, [verifyAndStartDelete]); + + const handlePremiumConfirmed = useCallback(() => { + if (pendingDeleteSettings && pendingDeleteCounts) { + void verifyAndStartDelete(pendingDeleteSettings, pendingDeleteCounts); + } + }, [pendingDeleteSettings, pendingDeleteCounts, verifyAndStartDelete]); + + const handleStartDelete = useCallback( + (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => { + void verifyAndStartDelete(settings, counts); + }, + [verifyAndStartDelete], + ); + const showFinishedModalWithJobs = useCallback( (jobs: BlueskyJobRecord[]) => { // Submit progress to the server regardless of success/failure @@ -353,7 +411,7 @@ export function DeleteTab({ selections={state} onBack={popScreen} onConfirm={handleStartDelete} - onPremiumRequired={handlePremiumRequired} + checkingPremium={checkingPremium} refreshKey={refreshKey} /> )} @@ -754,10 +812,7 @@ type DeleteReviewScreenProps = { settings: AccountDeleteSettings, counts: DeletionPreviewCounts, ) => void; - onPremiumRequired: ( - settings: AccountDeleteSettings, - counts: DeletionPreviewCounts, - ) => void; + checkingPremium: boolean; refreshKey: number; }; @@ -795,10 +850,9 @@ function DeleteReviewScreen({ selections, onBack, onConfirm, - onPremiumRequired, + checkingPremium, refreshKey: externalRefreshKey, }: DeleteReviewScreenProps) { - const { state: cydState } = useCydAccount(); const [counts, setCounts] = useState(null); const [countsLoading, setCountsLoading] = useState(true); const [countsError, setCountsError] = useState(null); @@ -867,32 +921,11 @@ function DeleteReviewScreen({ setRefreshKey((prev) => prev + 1); }, []); - // Handle Delete My Data button - check premium first + // The parent owns the fresh entitlement check at the execution boundary. const handleDeletePress = useCallback(() => { if (!counts) return; - - // If not signed in, show premium modal - if (!cydState.isSignedIn) { - onPremiumRequired(selections, counts); - return; - } - - // Check if user has premium (from context state) - if (cydState.hasPremiumAccess === true) { - // Has premium, proceed with deletion - onConfirm(selections, counts); - } else { - // No premium or unknown, show the modal - onPremiumRequired(selections, counts); - } - }, [ - counts, - cydState.isSignedIn, - cydState.hasPremiumAccess, - selections, - onConfirm, - onPremiumRequired, - ]); + onConfirm(selections, counts); + }, [counts, selections, onConfirm]); type DeletionItem = { label: string; @@ -1121,9 +1154,9 @@ function DeleteReviewScreen({ palette={palette} /> diff --git a/app/account/tabs/schedule-tab.tsx b/app/account/tabs/schedule-tab.tsx index 40979b3..1ba09e5 100644 --- a/app/account/tabs/schedule-tab.tsx +++ b/app/account/tabs/schedule-tab.tsx @@ -2,7 +2,7 @@ import { MaterialIcons } from "@expo/vector-icons"; import DateTimePicker from "@react-native-community/datetimepicker"; import * as Localization from "expo-localization"; import { useLocalSearchParams } from "expo-router"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, @@ -30,12 +30,14 @@ import { } from "@/components/account/shared-tab-styles"; import { LastActionTimestamp } from "@/components/LastActionTimestamp"; import { PremiumRequiredBanner } from "@/components/PremiumRequiredBanner"; +import { PremiumRequiredModal } from "@/components/PremiumRequiredModal"; import { SaveAndDeleteStatusBanner } from "@/components/SaveAndDeleteStatusBanner"; import { useCydAccount } from "@/contexts/CydAccountProvider"; import type { BlueskyJobRecord, SaveAndDeleteJobOptions, } from "@/controllers/bluesky/job-types"; +import { saveAndDeleteOptionsRequirePremium } from "@/controllers/bluesky/premium-feature-policy"; import { getAccountHandle, getLastDeletedAt, @@ -89,7 +91,7 @@ export function ScheduleTab({ palette, onSelectTab, }: AccountTabProps) { - const { apiClient, state: cydState } = useCydAccount(); + const { apiClient, state: cydState, checkPremiumAccess } = useCydAccount(); const params = useLocalSearchParams<{ scheduleShowReview?: string }>(); const showReviewOnLoad = params.scheduleShowReview === "true"; const [screenStack, setScreenStack] = useState([ @@ -114,6 +116,11 @@ export function ScheduleTab({ const [automationOptions, setAutomationOptions] = useState(null); const [automationKey, setAutomationKey] = useState(0); + const [checkingPremium, setCheckingPremium] = useState(false); + const [premiumModalVisible, setPremiumModalVisible] = useState(false); + const [pendingAutomationOptions, setPendingAutomationOptions] = + useState(null); + const premiumCheckInFlightRef = useRef(false); // Finished modal state const [finishedModalVisible, setFinishedModalVisible] = useState(false); @@ -333,7 +340,7 @@ export function ScheduleTab({ setAutomationOptions(null); }, []); - const handleStartAutomation = useCallback( + const startAutomation = useCallback( (options: SaveAndDeleteJobOptions) => { setAutomationOptions(options); setAutomationKey((prev) => prev + 1); @@ -342,6 +349,88 @@ export function ScheduleTab({ [], ); + const verifyAndStartAutomationRef = useRef< + (options: SaveAndDeleteJobOptions) => void + >(() => undefined); + + const verifyAndStartAutomation = useCallback( + async (options: SaveAndDeleteJobOptions) => { + if (premiumCheckInFlightRef.current) return; + + if (!saveAndDeleteOptionsRequirePremium(options)) { + startAutomation(options); + return; + } + + setPendingAutomationOptions(options); + premiumCheckInFlightRef.current = true; + setCheckingPremium(true); + + const showVerificationError = () => { + Alert.alert( + "Couldn’t verify Premium", + "Check your connection and try again.", + [ + { + text: "Cancel", + style: "cancel", + onPress: () => setPendingAutomationOptions(null), + }, + { + text: "Retry", + onPress: () => verifyAndStartAutomationRef.current(options), + }, + ], + ); + }; + + try { + const result = await checkPremiumAccess(); + if (result.status === "premium") { + setPremiumModalVisible(false); + setPendingAutomationOptions(null); + startAutomation(options); + } else if ( + result.status === "not_premium" || + result.status === "signed_out" + ) { + setPremiumModalVisible(true); + } else { + showVerificationError(); + } + } catch { + showVerificationError(); + } finally { + premiumCheckInFlightRef.current = false; + setCheckingPremium(false); + } + }, + [checkPremiumAccess, startAutomation], + ); + useEffect(() => { + verifyAndStartAutomationRef.current = (options) => { + void verifyAndStartAutomation(options); + }; + }, [verifyAndStartAutomation]); + + const handleStartAutomation = useCallback( + (options: SaveAndDeleteJobOptions) => { + void verifyAndStartAutomation(options); + }, + [verifyAndStartAutomation], + ); + + const handlePremiumDismiss = useCallback(() => { + setPremiumModalVisible(false); + setPendingAutomationOptions(null); + }, []); + + const handlePremiumConfirmed = useCallback(() => { + if (pendingAutomationOptions) { + void verifyAndStartAutomation(pendingAutomationOptions); + } + }, [pendingAutomationOptions, verifyAndStartAutomation]); + const showFinishedModalWithJobs = useCallback( (jobs: BlueskyJobRecord[]) => { // Update timestamps based on completed jobs @@ -442,6 +531,7 @@ export function ScheduleTab({ onBack={popScreen} onSelectTab={onSelectTab} onStartAutomation={handleStartAutomation} + checkingPremium={checkingPremium} /> )} {automationOptions && ( @@ -459,6 +549,12 @@ export function ScheduleTab({ onRestart={handleAutomationRestart} /> )} + void; onSelectTab?: (tab: AccountTabKey) => void; onStartAutomation: (options: SaveAndDeleteJobOptions) => void; + checkingPremium: boolean; }; function ScheduleReviewScreen({ @@ -730,6 +827,7 @@ function ScheduleReviewScreen({ onBack, onSelectTab, onStartAutomation, + checkingPremium, }: ScheduleReviewScreenProps) { const handleSaveAndDelete = useCallback(() => { const options: SaveAndDeleteJobOptions = { @@ -814,8 +912,13 @@ function ScheduleReviewScreen({ palette={palette} /> diff --git a/components/PremiumRequiredModal.tsx b/components/PremiumRequiredModal.tsx index 1a24b21..8ee0863 100644 --- a/components/PremiumRequiredModal.tsx +++ b/components/PremiumRequiredModal.tsx @@ -74,7 +74,13 @@ export function PremiumRequiredModal({ setCheckingPremium(true); void (async () => { try { - await checkPremiumAccess(); + const result = await checkPremiumAccess(); + if (result.status === "error") { + Alert.alert( + "Couldn’t verify Premium", + "Check your connection and try again.", + ); + } // The useEffect above will call onPremiumConfirmed if hasPremiumAccess becomes true // We just need to show appropriate feedback setCheckingPremium(false); @@ -117,7 +123,14 @@ export function PremiumRequiredModal({ const handleSignInClose = useCallback(() => { setShowSignInModal(false); // Re-check premium after sign-in modal closes - void checkPremiumAccess(); + void checkPremiumAccess().then((result) => { + if (result.status === "error") { + Alert.alert( + "Couldn’t verify Premium", + "Check your connection and try again.", + ); + } + }); }, [checkPremiumAccess]); const renderContent = () => { @@ -139,7 +152,8 @@ export function PremiumRequiredModal({ <> - Deleting data requires a Premium account. Sign in to get started. + Selected features require a Premium account. Sign in to get + started. {usesAppStoreIAP - ? "Deleting data requires a Premium account." - : "Deleting data requires a Premium account. Manage your account to upgrade to Premium."} + ? "Selected features require a Premium account." + : "Selected features require a Premium account. Manage your account to upgrade to Premium."} {usesAppStoreIAP ? ( diff --git a/components/__tests__/PremiumRequiredModal.test.tsx b/components/__tests__/PremiumRequiredModal.test.tsx index aeee997..577d9ad 100644 --- a/components/__tests__/PremiumRequiredModal.test.tsx +++ b/components/__tests__/PremiumRequiredModal.test.tsx @@ -108,6 +108,7 @@ describe("PremiumRequiredModal", () => { beforeEach(() => { jest.clearAllMocks(); + mockCheckPremiumAccess.mockResolvedValue({ status: "signed_out" }); // Reset to default signed out state mockUseCydAccount.mockReturnValue({ state: { @@ -178,7 +179,7 @@ describe("PremiumRequiredModal", () => { expect( screen.getByText( - "Deleting data requires a Premium account. Sign in to get started.", + "Selected features require a Premium account. Sign in to get started.", ), ).toBeTruthy(); expect(screen.getByText("Sign In")).toBeTruthy(); @@ -251,7 +252,7 @@ describe("PremiumRequiredModal", () => { // Use partial match since text spans multiple lines expect( - screen.getByText(/Deleting data requires a Premium account/i), + screen.getByText(/Selected features require a Premium account/i), ).toBeTruthy(); }); diff --git a/contexts/CydAccountProvider.tsx b/contexts/CydAccountProvider.tsx index 73596c4..76cec22 100644 --- a/contexts/CydAccountProvider.tsx +++ b/contexts/CydAccountProvider.tsx @@ -75,6 +75,12 @@ export type PremiumActionResult = { error?: string; }; +export type PremiumAccessCheckResult = + | { status: "premium" } + | { status: "not_premium" } + | { status: "signed_out" } + | { status: "error"; message: string }; + export type CydAccountContextType = { state: CydAccountState; apiClient: CydAPIClient; @@ -91,7 +97,7 @@ export type CydAccountContextType = { ) => Promise<{ success: boolean; error?: string }>; refreshState: () => Promise; getDashboardURL: () => string; - checkPremiumAccess: () => Promise; + checkPremiumAccess: () => Promise; purchasePremium: ( billingPeriod: BillingPeriod, ) => Promise; @@ -345,20 +351,59 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) { const checkPremiumAccess = useCallback(async () => { if (!state.isSignedIn) { setState((prev) => ({ ...prev, hasPremiumAccess: false })); - return; + return { status: "signed_out" } as const; } try { + const authentication = await apiClient.refreshAuthentication(); + if (authentication !== true) { + if (authentication.status === 401 || authentication.status === 403) { + await clearCydAccountCredentials(); + apiClient.setCredentials(null, null); + setState((prev) => ({ + ...prev, + isSignedIn: false, + userEmail: null, + hasPremiumAccess: false, + })); + return { status: "signed_out" } as const; + } + return { + status: "error", + message: authentication.message, + } as const; + } + const response = await apiClient.getUserPremium(); if ("error" in response) { - setState((prev) => ({ ...prev, hasPremiumAccess: false })); + if (response.status === 401 || response.status === 403) { + await clearCydAccountCredentials(); + apiClient.setCredentials(null, null); + setState((prev) => ({ + ...prev, + isSignedIn: false, + userEmail: null, + hasPremiumAccess: false, + })); + return { status: "signed_out" } as const; + } + return { status: "error", message: response.message } as const; } else { setState((prev) => ({ ...prev, hasPremiumAccess: response.premium_access, })); + return { + status: response.premium_access ? "premium" : "not_premium", + } as const; } - } catch { - setState((prev) => ({ ...prev, hasPremiumAccess: false })); + } catch (error) { + return { + status: "error", + message: + error instanceof Error + ? error.message + : "Failed to check Premium access.", + } as const; } }, [state.isSignedIn, apiClient]); diff --git a/contexts/__tests__/CydAccountProvider.test.tsx b/contexts/__tests__/CydAccountProvider.test.tsx index e9ef167..3d3072d 100644 --- a/contexts/__tests__/CydAccountProvider.test.tsx +++ b/contexts/__tests__/CydAccountProvider.test.tsx @@ -11,7 +11,10 @@ import { } from "expo-iap"; import React from "react"; -import { getCydAccountCredentials } from "@/database/cyd-account"; +import { + clearCydAccountCredentials, + getCydAccountCredentials, +} from "@/database/cyd-account"; import { CydAccountProvider, type CydAccountContextType, @@ -20,6 +23,8 @@ import { const mockSyncAppStoreSubscription = jest.fn(); const mockGetAppStoreSubscription = jest.fn(); +const mockGetUserPremium = jest.fn(); +const mockRefreshAuthentication = jest.fn(); const mockPing = jest.fn(() => Promise.resolve(false)); jest.mock("@/constants/subscriptions", () => ({ @@ -58,12 +63,25 @@ jest.mock("@/services/cyd-api-client", () => { postUserActivity: jest.fn(() => Promise.resolve(true)), deleteDevice: jest.fn(() => Promise.resolve()), getDashboardURL: jest.fn(() => "https://dash.cyd.social"), + getUserPremium: mockGetUserPremium, + refreshAuthentication: mockRefreshAuthentication, getAppStoreSubscription: mockGetAppStoreSubscription, syncAppStoreSubscription: mockSyncAppStoreSubscription, })); }); describe("CydAccountProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPing.mockResolvedValue(false); + mockRefreshAuthentication.mockResolvedValue(true); + (getCydAccountCredentials as jest.Mock).mockResolvedValue({ + userEmail: null, + deviceToken: null, + deviceUUID: null, + }); + }); + describe("exports", () => { it("should export CydAccountProvider component", () => { expect(CydAccountProvider).toBeDefined(); @@ -213,4 +231,158 @@ describe("CydAccountProvider", () => { }); }); }); + + describe("Premium access verification", () => { + it("returns the fresh non-Premium result instead of cached access", async () => { + mockPing.mockResolvedValue(true); + (getCydAccountCredentials as jest.Mock).mockResolvedValue({ + userEmail: "expired@example.com", + deviceToken: "device-token", + deviceUUID: "device-uuid", + }); + mockGetUserPremium.mockResolvedValue({ premium_access: false }); + + let context: CydAccountContextType | null = null; + function ContextReader() { + context = useCydAccount(); + return null; + } + + render( + + + , + ); + + await waitFor(() => expect(context?.state.isSignedIn).toBe(true)); + + let result; + await act(async () => { + result = await context?.checkPremiumAccess(); + }); + + expect(result).toEqual({ status: "not_premium" }); + expect(context?.state.hasPremiumAccess).toBe(false); + }); + + it("signs out and clears credentials when the server rejects the device session", async () => { + mockPing.mockResolvedValue(true); + (getCydAccountCredentials as jest.Mock).mockResolvedValue({ + userEmail: "revoked@example.com", + deviceToken: "revoked-device-token", + deviceUUID: "device-uuid", + }); + mockGetUserPremium.mockResolvedValue({ + error: true, + message: "Authentication failed", + status: 401, + }); + + let context: CydAccountContextType | null = null; + function ContextReader() { + context = useCydAccount(); + return null; + } + + render( + + + , + ); + await waitFor(() => expect(context?.state.isSignedIn).toBe(true)); + + let result; + await act(async () => { + result = await context?.checkPremiumAccess(); + }); + + expect(result).toEqual({ status: "signed_out" }); + expect(clearCydAccountCredentials).toHaveBeenCalled(); + expect(context?.state).toMatchObject({ + isSignedIn: false, + userEmail: null, + hasPremiumAccess: false, + }); + }); + + it("refreshes device authentication before checking Premium access", async () => { + mockPing.mockResolvedValue(true); + (getCydAccountCredentials as jest.Mock).mockResolvedValue({ + userEmail: "revoked@example.com", + deviceToken: "revoked-device-token", + deviceUUID: "device-uuid", + }); + mockRefreshAuthentication.mockResolvedValue({ + error: true, + message: "Failed to get token with the server.", + status: 401, + }); + mockGetUserPremium.mockResolvedValue({ premium_access: true }); + + let context: CydAccountContextType | null = null; + function ContextReader() { + context = useCydAccount(); + return null; + } + + render( + + + , + ); + await waitFor(() => expect(context?.state.isSignedIn).toBe(true)); + + let result; + await act(async () => { + result = await context?.checkPremiumAccess(); + }); + + expect(result).toEqual({ status: "signed_out" }); + expect(mockRefreshAuthentication).toHaveBeenCalledTimes(1); + expect(mockGetUserPremium).not.toHaveBeenCalled(); + expect(clearCydAccountCredentials).toHaveBeenCalled(); + }); + + it("preserves the signed-in state when verification fails temporarily", async () => { + mockPing.mockResolvedValue(true); + (getCydAccountCredentials as jest.Mock).mockResolvedValue({ + userEmail: "subscriber@example.com", + deviceToken: "device-token", + deviceUUID: "device-uuid", + }); + mockGetUserPremium.mockResolvedValue({ + error: true, + message: "Premium service unavailable", + status: 503, + }); + + let context: CydAccountContextType | null = null; + function ContextReader() { + context = useCydAccount(); + return null; + } + + render( + + + , + ); + await waitFor(() => expect(context?.state.isSignedIn).toBe(true)); + + let result; + await act(async () => { + result = await context?.checkPremiumAccess(); + }); + + expect(result).toEqual({ + status: "error", + message: "Premium service unavailable", + }); + expect(context?.state).toMatchObject({ + isSignedIn: true, + userEmail: "subscriber@example.com", + hasPremiumAccess: null, + }); + }); + }); }); diff --git a/controllers/bluesky/__tests__/premium-feature-policy.test.ts b/controllers/bluesky/__tests__/premium-feature-policy.test.ts new file mode 100644 index 0000000..88fcac1 --- /dev/null +++ b/controllers/bluesky/__tests__/premium-feature-policy.test.ts @@ -0,0 +1,75 @@ +import type { AccountDeleteSettings } from "@/database/delete-settings"; + +import { + deleteSettingsRequirePremium, + saveAndDeleteOptionsRequirePremium, +} from "../premium-feature-policy"; + +const noDeletesSelected: AccountDeleteSettings = { + deletePosts: false, + deletePostsDaysOldEnabled: false, + deletePostsDaysOld: 0, + deletePostsLikesThresholdEnabled: false, + deletePostsLikesThreshold: 0, + deletePostsRepostsThresholdEnabled: false, + deletePostsRepostsThreshold: 0, + deletePostsPreserveThreads: false, + deleteReposts: false, + deleteRepostsDaysOldEnabled: false, + deleteRepostsDaysOld: 0, + deleteLikes: false, + deleteLikesDaysOldEnabled: false, + deleteLikesDaysOld: 0, + deleteBookmarks: false, + deleteChats: false, + deleteChatsDaysOldEnabled: false, + deleteChatsDaysOld: 0, + deleteUnfollowEveryone: false, +}; + +describe("premium feature policy", () => { + it("allows every save feature without Premium when no delete feature is selected", () => { + expect( + saveAndDeleteOptionsRequirePremium({ + saveOptions: { + posts: true, + likes: true, + bookmarks: true, + chat: true, + }, + deleteOptions: { settings: noDeletesSelected }, + }), + ).toBe(false); + }); + + it.each([ + "deletePosts", + "deleteReposts", + "deleteLikes", + "deleteBookmarks", + "deleteChats", + "deleteUnfollowEveryone", + ])("requires Premium when %s is selected", (feature) => { + expect( + deleteSettingsRequirePremium({ + ...noDeletesSelected, + [feature]: true, + }), + ).toBe(true); + }); + + it("ignores delete filters when their parent delete feature is not selected", () => { + expect( + deleteSettingsRequirePremium({ + ...noDeletesSelected, + deletePostsDaysOldEnabled: true, + deletePostsLikesThresholdEnabled: true, + deletePostsRepostsThresholdEnabled: true, + deletePostsPreserveThreads: true, + deleteRepostsDaysOldEnabled: true, + deleteLikesDaysOldEnabled: true, + deleteChatsDaysOldEnabled: true, + }), + ).toBe(false); + }); +}); diff --git a/controllers/bluesky/premium-feature-policy.ts b/controllers/bluesky/premium-feature-policy.ts new file mode 100644 index 0000000..f60188a --- /dev/null +++ b/controllers/bluesky/premium-feature-policy.ts @@ -0,0 +1,21 @@ +import type { SaveAndDeleteJobOptions } from "@/controllers/bluesky/job-types"; +import type { AccountDeleteSettings } from "@/database/delete-settings"; + +export function deleteSettingsRequirePremium( + settings: AccountDeleteSettings, +): boolean { + return Boolean( + settings.deletePosts || + settings.deleteReposts || + settings.deleteLikes || + settings.deleteBookmarks || + settings.deleteChats || + settings.deleteUnfollowEveryone + ); +} + +export function saveAndDeleteOptionsRequirePremium( + options: SaveAndDeleteJobOptions, +): boolean { + return deleteSettingsRequirePremium(options.deleteOptions.settings); +} diff --git a/services/__tests__/cyd-api-client.test.ts b/services/__tests__/cyd-api-client.test.ts index b82e820..634e953 100644 --- a/services/__tests__/cyd-api-client.test.ts +++ b/services/__tests__/cyd-api-client.test.ts @@ -94,6 +94,38 @@ describe("CydAPIClient", () => { ); expect(premium).toEqual({ premium_access: true }); }); + + it("force-refreshes authentication instead of reusing a cached bearer token", async () => { + client.setCredentials("revoked@example.com", "revoked-device-token"); + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + api_token: "cached-api-token", + device_uuid: "device-uuid", + email: "revoked@example.com", + }), + }) + .mockResolvedValueOnce({ status: 200 }) + .mockResolvedValueOnce({ status: 401 }); + + expect(await client.ping()).toBe(true); + + expect(await client.refreshAuthentication()).toEqual({ + error: true, + message: "Failed to get token with the server.", + status: 401, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + `${API_URL}/token`, + expect.objectContaining({ + body: JSON.stringify({ + email: "revoked@example.com", + device_token: "revoked-device-token", + }), + }), + ); + }); }); describe("setUserEmail", () => { @@ -359,6 +391,19 @@ describe("CydAPIClient", () => { }); describe("getUserPremium", () => { + it("preserves the authentication status when the device token is rejected", async () => { + client.setCredentials("revoked@example.com", "revoked-device-token"); + mockFetch.mockResolvedValue({ status: 401 }); + + const result = await client.getUserPremium(); + + expect(result).toEqual({ + error: true, + message: "Failed to get a new API token.", + status: 401, + }); + }); + it("should return premium info on success", async () => { const premiumResponse = { premium_price_annual_cents: 3599, diff --git a/services/cyd-api-client.ts b/services/cyd-api-client.ts index 7b79dad..fb0089c 100644 --- a/services/cyd-api-client.ts +++ b/services/cyd-api-client.ts @@ -153,6 +153,7 @@ export default class CydAPIClient { private deviceToken: string | null = null; private apiToken: string | null = null; private deviceUUID: string | null = null; + private lastAPITokenErrorStatus: number | undefined; constructor(apiURL: string, dashURL: string) { this.apiURL = apiURL; @@ -277,6 +278,7 @@ export default class CydAPIClient { private async getNewAPIToken(): Promise { console.log("Getting a new API token"); + this.lastAPITokenErrorStatus = undefined; if ( typeof this.userEmail === "string" && this.userEmail !== "" && @@ -288,6 +290,7 @@ export default class CydAPIClient { device_token: this.deviceToken, }); if ("error" in getTokenResp) { + this.lastAPITokenErrorStatus = getTokenResp.status; console.log("Failed to get a new API token", getTokenResp.message); return false; } @@ -304,6 +307,22 @@ export default class CydAPIClient { return await this.getNewAPIToken(); } + async refreshAuthentication(): Promise { + this.invalidateAuthentication(); + if (!this.userEmail || !this.deviceToken) { + return this.returnError("Cyd account credentials are missing.", 401); + } + + const response = await this.getToken({ + email: this.userEmail, + device_token: this.deviceToken, + }); + if ("error" in response) { + return response; + } + return true; + } + // Auth API (not authenticated) async authenticate( @@ -459,7 +478,10 @@ export default class CydAPIClient { async getUserPremium(): Promise { console.log("GET /user/premium"); if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); + return this.returnError( + "Failed to get a new API token.", + this.lastAPITokenErrorStatus, + ); } try { const response = await this.fetchAuthenticated(