From 21dda73730557b5827a0eec6e3cc2a53f9ff02bb Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Sun, 19 Jul 2026 16:45:42 -0700 Subject: [PATCH 1/3] Chat saving skipped messages with empty text, so shared-post embeds were never stored locally and thus never deletable, leaving conversations that couldn't be cleaned up. - chat-indexer: save all real messages (isMessageView) instead of filtering on text; skip only placeholders - getConversationMessageCount: count only real messageViews - delete-messages: run empty-conversation cleanup from the DB even with no messages to delete; schedule the job whenever deleteChats is on - FinishedModal: report conversations left - update tests/fixtures accordingly --- app/account/components/FinishedModal.tsx | 26 ++++++---- controllers/BlueskyAccountController.ts | 43 +++++++++++++++-- .../bluesky/__tests__/chat-indexer.test.ts | 23 +++++++-- .../bluesky/__tests__/delete-jobs.test.ts | 47 ++++++++++++++++++- controllers/bluesky/chat-indexer.ts | 29 +++++------- controllers/bluesky/jobs/delete-messages.ts | 31 ++++-------- testUtils/blueskyFixtures.ts | 4 ++ 7 files changed, 145 insertions(+), 58 deletions(-) diff --git a/app/account/components/FinishedModal.tsx b/app/account/components/FinishedModal.tsx index 043c8bd..f4150d9 100644 --- a/app/account/components/FinishedModal.tsx +++ b/app/account/components/FinishedModal.tsx @@ -2,12 +2,12 @@ import { useModalBottomPadding } from "@/hooks/use-modal-bottom-padding"; import { MaterialIcons } from "@expo/vector-icons"; import React, { useMemo } from "react"; import { - Modal, - Pressable, - ScrollView, - StyleSheet, - Text, - View, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + View, } from "react-native"; import type { BlueskyJobRecord } from "@/controllers/bluesky/job-types"; @@ -84,6 +84,7 @@ type MaybeProgress = { messagesProgress?: { current?: number | null }; currentItemIndex?: number | null; totalItems?: number | null; + conversationsLeft?: number | null; }; function extractSavedCount(job: BlueskyJobRecord): number | null { @@ -144,8 +145,17 @@ function formattedSavedCount(job: BlueskyJobRecord): string | null { return `Deleted ${count.toLocaleString()} likes`; case "deleteBookmarks": return `Deleted ${count.toLocaleString()} bookmarks`; - case "deleteMessages": - return `Deleted ${count.toLocaleString()} messages`; + case "deleteMessages": { + const progress = job.progress as MaybeProgress | undefined; + const conversationsLeft = progress?.conversationsLeft ?? 0; + const conversationSuffix = + conversationsLeft > 0 + ? `, left ${conversationsLeft.toLocaleString()} conversation${ + conversationsLeft > 1 ? "s" : "" + }` + : ""; + return `Deleted ${count.toLocaleString()} messages${conversationSuffix}`; + } case "unfollowUsers": return `Unfollowed ${count.toLocaleString()} accounts`; default: diff --git a/controllers/BlueskyAccountController.ts b/controllers/BlueskyAccountController.ts index 4548c1d..679c0ce 100644 --- a/controllers/BlueskyAccountController.ts +++ b/controllers/BlueskyAccountController.ts @@ -1,4 +1,8 @@ -import { Agent, type AppBskyActorDefs } from "@atproto/api"; +import { + Agent, + type AppBskyActorDefs, + ChatBskyConvoDefs, +} from "@atproto/api"; import type { OAuthSession } from "@atproto/oauth-client"; import { Directory, File, Paths } from "expo-file-system"; import * as Sharing from "expo-sharing"; @@ -606,7 +610,10 @@ export class BlueskyAccountController extends BaseAccountController 0) { + // Schedule the delete-messages job whenever chat deletion is enabled, even + // when there are no messages to delete: the job also cleans up (leaves) + // empty conversations, which have no rows in the message table. + if (settings.deleteChats) { jobTypes.push("deleteMessages"); } @@ -1320,7 +1327,31 @@ export class BlueskyAccountController extends BaseAccountController( + `SELECT c.convoId FROM conversation c + WHERE c.leftAt IS NULL + AND NOT EXISTS ( + SELECT 1 FROM message m + WHERE m.convoId = c.convoId AND m.deletedAt IS NULL + );`, + ); + return rows.map((r) => r.convoId); + } + + /** + * Get the count of real messages remaining in a conversation from the + * Bluesky API. Only counts messageView entries — placeholders such as + * deletedMessageView (returned for messages the user deleted for themselves) + * and systemMessageView don't count, so a conversation whose messages have + * all been deleted is correctly reported as empty and can be left. */ async getConversationMessageCount(convoId: string): Promise { const agent = this.requireAgent(); @@ -1333,13 +1364,15 @@ export class BlueskyAccountController extends BaseAccountController + ChatBskyConvoDefs.isMessageView(message), + ).length; } /** diff --git a/controllers/bluesky/__tests__/chat-indexer.test.ts b/controllers/bluesky/__tests__/chat-indexer.test.ts index 5eab48e..c39cec6 100644 --- a/controllers/bluesky/__tests__/chat-indexer.test.ts +++ b/controllers/bluesky/__tests__/chat-indexer.test.ts @@ -285,6 +285,7 @@ describe("ChatIndexer", () => { it("should still emit a preview for messages without an id", async () => { const messages = [ { + $type: "chat.bsky.convo.defs#messageView", text: "Message without an id", sentAt: "2026-01-04T12:00:00.000Z", sender: { did: "did:plc:sender" }, @@ -528,14 +529,25 @@ describe("ChatIndexer", () => { }); }); - it("should skip messages without text", async () => { + it("should save empty-text messages but skip non-message views", async () => { const messages = [ createChatMessage({ text: "Valid message" }), + // A message that is only a shared-post embed has empty text but is a + // real message that must be saved (and later deletable). + createChatMessage({ + id: "embed-only", + text: "", + embed: { $type: "app.bsky.embed.record#view" }, + }), + // A deletedMessageView placeholder (returned after deleteMessageForSelf) + // has no content and must be skipped. { - id: "msg2", + $type: "chat.bsky.convo.defs#deletedMessageView", + id: "deleted1", + rev: "1", sender: { did: "did:plc:sender" }, sentAt: new Date().toISOString(), - }, // No text + }, createChatMessage({ text: "Another valid message" }), ]; @@ -549,7 +561,8 @@ describe("ChatIndexer", () => { const indexer = new ChatIndexer(deps); await indexer.indexChatMessages(); - // Should only save messages with text (2, not 3) + // Should save the 3 real messages (including the empty-text embed) and + // skip the deletedMessageView placeholder. const runAsyncCalls = (mockDb.runAsync as jest.Mock).mock .calls as unknown[][]; const messageInserts = runAsyncCalls.filter( @@ -558,7 +571,7 @@ describe("ChatIndexer", () => { call[0].includes("INSERT") && call[0].includes("message"), ); - expect(messageInserts.length).toBe(2); + expect(messageInserts.length).toBe(3); }); }); }); diff --git a/controllers/bluesky/__tests__/delete-jobs.test.ts b/controllers/bluesky/__tests__/delete-jobs.test.ts index 6153348..65267ec 100644 --- a/controllers/bluesky/__tests__/delete-jobs.test.ts +++ b/controllers/bluesky/__tests__/delete-jobs.test.ts @@ -84,6 +84,9 @@ function createMockController(overrides?: Partial) { getMessagesToDelete: jest.fn().mockReturnValue([]), deleteMessage: jest.fn().mockResolvedValue(undefined), getProfilesByDids: jest.fn().mockReturnValue(new Map()), + getConversationIdsToCleanup: jest.fn().mockReturnValue([]), + getConversationMessageCount: jest.fn().mockResolvedValue(0), + leaveConversation: jest.fn().mockResolvedValue(undefined), // Follow-related fetchFollowsFromApi: jest.fn().mockResolvedValue([]), unfollowUser: jest.fn().mockResolvedValue(undefined), @@ -536,7 +539,49 @@ describe("Delete Job Runners", () => { ); const lastCall = calls[calls.length - 1]; - expect(lastCall.progressMessage).toBe("No messages to delete"); + expect(lastCall.progressMessage).toBe("Deleted 0 messages"); + }); + + it("should leave empty conversations even when there are no messages to delete", async () => { + const controller = createMockController({ + getMessagesToDelete: jest.fn().mockReturnValue([]), + getConversationIdsToCleanup: jest.fn().mockReturnValue(["convo-empty"]), + getConversationMessageCount: jest.fn().mockResolvedValue(0), + }); + const { emit, calls } = createMockEmit(); + + await runDeleteMessagesJob( + controller, + { ...mockJob, jobType: "deleteMessages" }, + emit, + ); + + expect(controller.leaveConversation).toHaveBeenCalledWith("convo-empty"); + + const lastCall = calls[calls.length - 1]; + expect(lastCall.progressMessage).toBe( + "Deleted 0 messages, left 1 empty conversation", + ); + }); + + it("should not leave conversations that still have messages on the server", async () => { + const controller = createMockController({ + getMessagesToDelete: jest.fn().mockReturnValue([]), + getConversationIdsToCleanup: jest.fn().mockReturnValue(["convo-full"]), + getConversationMessageCount: jest.fn().mockResolvedValue(3), + }); + const { emit, calls } = createMockEmit(); + + await runDeleteMessagesJob( + controller, + { ...mockJob, jobType: "deleteMessages" }, + emit, + ); + + expect(controller.leaveConversation).not.toHaveBeenCalled(); + + const lastCall = calls[calls.length - 1]; + expect(lastCall.progressMessage).toBe("Deleted 0 messages"); }); it("should delete messages and emit progress", async () => { diff --git a/controllers/bluesky/chat-indexer.ts b/controllers/bluesky/chat-indexer.ts index 722168b..7cb4458 100644 --- a/controllers/bluesky/chat-indexer.ts +++ b/controllers/bluesky/chat-indexer.ts @@ -1,6 +1,7 @@ import { Agent, type AppBskyActorDefs, + ChatBskyConvoDefs, type ChatBskyConvoGetMessages, type ChatBskyConvoListConvos, } from "@atproto/api"; @@ -354,23 +355,15 @@ export class ChatIndexer { const now = Date.now(); for (const message of messages) { - // Type guard and extract message data - const msg = message as - | { - id?: string; - rev?: string; - text?: string; - sentAt?: string; - sender?: { did?: string }; - facets?: unknown[]; - embed?: unknown; - } - | undefined; - - // Only process messages that have text content - if (!msg?.text) { + // Only persist real messages. getMessages also returns placeholders such + // as deletedMessageView (for messages deleted-for-self) and + // systemMessageView, which have no content to save. Note a real + // messageView may still have empty text (e.g. a message that's only a + // shared-post embed), so we must NOT filter on text content. + if (!ChatBskyConvoDefs.isMessageView(message)) { continue; } + const msg = message; await db.runAsync( `INSERT OR REPLACE INTO message ( @@ -534,7 +527,9 @@ export class ChatIndexer { } | undefined; - if (!msg?.text || !msg.sender?.did) return null; + // A message may legitimately have empty text (e.g. a shared-post embed), + // so only require a sender to build a preview. + if (!msg?.sender?.did) return null; // Fetch the profile from the database const profile = await db.getFirstAsync<{ @@ -559,7 +554,7 @@ export class ChatIndexer { return { messageId: msg.id ?? fallbackMessageId, convoId, - text: msg.text, + text: msg.text ?? "", sentAt: msg.sentAt ?? new Date().toISOString(), savedAt: new Date().toISOString(), deletedAt: null, diff --git a/controllers/bluesky/jobs/delete-messages.ts b/controllers/bluesky/jobs/delete-messages.ts index dbb8155..eebaeaa 100644 --- a/controllers/bluesky/jobs/delete-messages.ts +++ b/controllers/bluesky/jobs/delete-messages.ts @@ -31,22 +31,9 @@ export async function runDeleteMessagesJob( const messagesToDelete = controller.getMessagesToDelete(settings); const total = messagesToDelete.length; - if (total === 0) { - emit({ - progressMessage: "No messages to delete", - progressPercent: 1, - unknownTotal: false, - progress: { currentItemIndex: 0, totalItems: 0 }, - }); - return; - } - let deleted = 0; let errors = 0; - // Track unique conversation IDs for cleanup check - const conversationIds = new Set(); - // Get signed-in user info for display (includes avatar from local DB) const userProfile = controller.getUserProfileData(); const userDid = userProfile?.did ?? ""; @@ -71,9 +58,6 @@ export async function runDeleteMessagesJob( for (const message of messagesToDelete) { await controller.waitForPause(); - // Track this conversation for later cleanup check - conversationIds.add(message.convoId); - // Find the recipient (the other person in the conversation, not the current user) let recipient: { did: string; @@ -146,15 +130,18 @@ export async function runDeleteMessagesJob( } } - // Check for empty conversations and clean them up + // Check for empty conversations and clean them up. This is sourced from the + // DB (not from messagesToDelete) so it also covers conversations that have no + // saved messages at all — those never appear in messagesToDelete. let conversationsLeft = 0; - const conversationArray = Array.from(conversationIds); + const conversationArray = controller.getConversationIdsToCleanup(); - for (const convoId of conversationArray) { + for (let i = 0; i < conversationArray.length; i++) { + const convoId = conversationArray[i]; await controller.waitForPause(); emit({ - progressMessage: `Checking conversation ${(conversationsLeft + 1).toLocaleString()} of ${conversationArray.length.toLocaleString()} for cleanup…`, + progressMessage: `Checking conversation ${(i + 1).toLocaleString()} of ${conversationArray.length.toLocaleString()} for cleanup…`, progressPercent: 1, unknownTotal: false, }); @@ -165,7 +152,7 @@ export async function runDeleteMessagesJob( if (messageCount === 0) { emit({ - progressMessage: `Leaving empty conversation ${(conversationsLeft + 1).toLocaleString()} of ${conversationArray.length.toLocaleString()}…`, + progressMessage: `Leaving empty conversation ${(i + 1).toLocaleString()} of ${conversationArray.length.toLocaleString()}…`, progressPercent: 1, unknownTotal: false, }); @@ -192,6 +179,6 @@ export async function runDeleteMessagesJob( progressMessage: `Deleted ${deleted.toLocaleString()} messages${errors > 0 ? ` (${errors.toLocaleString()} failed)` : ""}${conversationMessage}`, progressPercent: 1, unknownTotal: false, - progress: { currentItemIndex: deleted, totalItems: total }, + progress: { currentItemIndex: deleted, totalItems: total, conversationsLeft }, }); } diff --git a/testUtils/blueskyFixtures.ts b/testUtils/blueskyFixtures.ts index 42212c8..3b90b4c 100644 --- a/testUtils/blueskyFixtures.ts +++ b/testUtils/blueskyFixtures.ts @@ -353,6 +353,7 @@ export function createPostWithEngagement(counts: { // Chat message fixtures export interface MockChatMessage { + $type: string; id: string; rev: string; text: string; @@ -366,6 +367,9 @@ export function createChatMessage( overrides?: Partial ): MockChatMessage { return { + // Real getMessages responses always tag entries with a $type; the indexer + // relies on it to distinguish real messages from placeholders. + $type: overrides?.$type ?? "chat.bsky.convo.defs#messageView", id: overrides?.id ?? `msg${Date.now()}`, rev: overrides?.rev ?? "1", text: overrides?.text ?? "Hello, this is a test message", From 8d496ec7fd200a6151f65cd1a8358ebbebb9b6e3 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Sun, 19 Jul 2026 16:53:00 -0700 Subject: [PATCH 2/3] Make lint run typecheck too, and fix all type errors --- components/__tests__/PostsToDeleteReviewModal.test.tsx | 9 ++------- components/cyd/__tests__/SpeechBubble.test.tsx | 5 ++++- controllers/__tests__/BaseAccountController.test.ts | 3 ++- .../__tests__/BlueskyAccountController.jobs.test.ts | 6 ++---- package.json | 3 ++- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/components/__tests__/PostsToDeleteReviewModal.test.tsx b/components/__tests__/PostsToDeleteReviewModal.test.tsx index bd5ef7c..c69784c 100644 --- a/components/__tests__/PostsToDeleteReviewModal.test.tsx +++ b/components/__tests__/PostsToDeleteReviewModal.test.tsx @@ -1,6 +1,7 @@ import { render, waitFor } from "@testing-library/react-native"; import React from "react"; +import { Colors } from "@/constants/theme"; import { PostsToDeleteReviewModal } from "../PostsToDeleteReviewModal"; const mockGetBlueskyController = jest.fn(); @@ -33,13 +34,7 @@ jest.mock("@/controllers", () => ({ }), })); -const palette = { - background: "#fff", - card: "#fff", - text: "#111", - tint: "#00f", - icon: "#666", -} as const; +const palette = Colors.light; const selections = { deletePosts: true, diff --git a/components/cyd/__tests__/SpeechBubble.test.tsx b/components/cyd/__tests__/SpeechBubble.test.tsx index 2ed805b..5a8cdbe 100644 --- a/components/cyd/__tests__/SpeechBubble.test.tsx +++ b/components/cyd/__tests__/SpeechBubble.test.tsx @@ -1,5 +1,6 @@ import React from "react"; +import type { AppColorScheme } from "@/hooks/use-color-scheme"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { SpeechBubble } from "../SpeechBubble"; @@ -129,7 +130,9 @@ describe("SpeechBubble", () => { }); it("should work with null color scheme (fallback)", () => { - mockUseColorScheme.mockReturnValue(null); + // The app hook always normalizes to a concrete scheme, but verify the + // component tolerates an unexpected null at runtime. + mockUseColorScheme.mockReturnValue(null as unknown as AppColorScheme); expect(() => React.createElement(SpeechBubble, { message: "Test" }) diff --git a/controllers/__tests__/BaseAccountController.test.ts b/controllers/__tests__/BaseAccountController.test.ts index 5e41961..47ad99c 100644 --- a/controllers/__tests__/BaseAccountController.test.ts +++ b/controllers/__tests__/BaseAccountController.test.ts @@ -170,7 +170,8 @@ describe("BaseAccountController", () => { expect(dbB).toBeTruthy(); expect(dbA).toBe(dbB); - const closeAsync = (dbA as { closeAsync: jest.Mock }).closeAsync; + const closeAsync = (dbA as unknown as { closeAsync: jest.Mock }) + .closeAsync; await controllerA.cleanup(); expect(closeAsync).toHaveBeenCalledTimes(0); diff --git a/controllers/__tests__/BlueskyAccountController.jobs.test.ts b/controllers/__tests__/BlueskyAccountController.jobs.test.ts index a1c14f4..1701f02 100644 --- a/controllers/__tests__/BlueskyAccountController.jobs.test.ts +++ b/controllers/__tests__/BlueskyAccountController.jobs.test.ts @@ -262,7 +262,7 @@ describe("BlueskyAccountController job pipeline", () => { (controller as unknown as { agent: unknown }).agent = {}; - let resolveIndexPosts: (() => void) | null = null; + let resolveIndexPosts!: () => void; const indexPostsPromise = new Promise((resolve) => { resolveIndexPosts = resolve; }); @@ -277,9 +277,7 @@ describe("BlueskyAccountController job pipeline", () => { "Automation already running for this account.", ); - if (resolveIndexPosts) { - resolveIndexPosts(); - } + resolveIndexPosts(); await expect(firstRunPromise).resolves.toBeUndefined(); }); diff --git a/package.json b/package.json index 74a2a4b..a700c78 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "submit:ios": "./scripts/ci/submit-ios.sh", "build:android": "JAVA_HOME=$(/usr/libexec/java_home -v 17) ANDROID_HOME=$HOME/Library/Android/sdk eas build --platform android --profile production --local --non-interactive", "submit:android": "node ./scripts/ci/submit-android.js", - "lint": "expo lint", + "typecheck": "tsc --noEmit", + "lint": "expo lint && tsc --noEmit", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" From dd3b4207f4bc945824f41e354264a2af1cd91c20 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Sun, 19 Jul 2026 17:35:59 -0700 Subject: [PATCH 3/3] fix: revalidate premium before deletion --- .../__tests__/delete-tab-premium.test.tsx | 47 ++- .../__tests__/schedule-tab-premium.test.tsx | 289 ++++++++++++++++++ app/account/tabs/delete-tab.tsx | 163 ++++++---- app/account/tabs/schedule-tab.tsx | 111 ++++++- components/PremiumRequiredModal.tsx | 24 +- .../__tests__/PremiumRequiredModal.test.tsx | 5 +- contexts/CydAccountProvider.tsx | 55 +++- .../__tests__/CydAccountProvider.test.tsx | 174 ++++++++++- .../__tests__/premium-feature-policy.test.ts | 75 +++++ controllers/bluesky/premium-feature-policy.ts | 21 ++ services/__tests__/cyd-api-client.test.ts | 45 +++ services/cyd-api-client.ts | 24 +- 12 files changed, 944 insertions(+), 89 deletions(-) create mode 100644 app/account/tabs/__tests__/schedule-tab-premium.test.tsx create mode 100644 controllers/bluesky/__tests__/premium-feature-policy.test.ts create mode 100644 controllers/bluesky/premium-feature-policy.ts 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(