diff --git a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.test.ts b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.test.ts index c3075324..afe91307 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.test.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.test.ts @@ -57,7 +57,11 @@ describe("helpers_unfollow.ts", () => { it("should successfully unfollow an account", async () => { const result = await DeleteHelpers.unfollowEveryoneUnfollowAccount(vm, 0); - expect(result).toEqual({ success: true, shouldReload: false }); + expect(result).toEqual({ + success: true, + shouldRetry: false, + shouldReload: false, + }); expect(vm.scriptMouseoverElementNth).toHaveBeenCalledWith( 'div[data-testid="cellInnerDiv"] button button', 0, @@ -72,6 +76,8 @@ describe("helpers_unfollow.ts", () => { expect(vm.scriptClickElement).toHaveBeenCalledWith( 'button[data-testid="confirmationSheetConfirm"]', ); + expect(mockElectron.X.isRateLimited).toHaveBeenCalled(); + expect(vm.waitForRateLimit).not.toHaveBeenCalled(); }); it("should fail if mouseover fails", async () => { @@ -79,7 +85,11 @@ describe("helpers_unfollow.ts", () => { const result = await DeleteHelpers.unfollowEveryoneUnfollowAccount(vm, 0); - expect(result).toEqual({ success: false, shouldReload: true }); + expect(result).toEqual({ + success: false, + shouldRetry: false, + shouldReload: true, + }); }); it("should fail if click following button fails", async () => { @@ -87,7 +97,11 @@ describe("helpers_unfollow.ts", () => { const result = await DeleteHelpers.unfollowEveryoneUnfollowAccount(vm, 0); - expect(result).toEqual({ success: false, shouldReload: true }); + expect(result).toEqual({ + success: false, + shouldRetry: false, + shouldReload: true, + }); }); it("should handle errors during confirmation", async () => { @@ -99,7 +113,48 @@ describe("helpers_unfollow.ts", () => { const result = await DeleteHelpers.unfollowEveryoneUnfollowAccount(vm, 0); - expect(result).toEqual({ success: false, shouldReload: true }); + expect(result).toEqual({ + success: false, + shouldRetry: false, + shouldReload: true, + }); + expect(vm.waitForRateLimit).not.toHaveBeenCalled(); + }); + + it("should wait and retry when the confirm button never appears due to a rate limit", async () => { + vi.spyOn(vm, "waitForSelector").mockRejectedValue(new Error("Error")); + mockElectron.X.isRateLimited.mockResolvedValue({ + isRateLimited: true, + rateLimitReset: 0, + }); + + const result = await DeleteHelpers.unfollowEveryoneUnfollowAccount(vm, 0); + + expect(result).toEqual({ + success: false, + shouldRetry: true, + shouldReload: true, + }); + expect(vm.waitForRateLimit).toHaveBeenCalled(); + }); + + it("should wait and retry when rate limited after clicking confirm", async () => { + mockElectron.X.isRateLimited.mockResolvedValue({ + isRateLimited: true, + rateLimitReset: 0, + }); + + const result = await DeleteHelpers.unfollowEveryoneUnfollowAccount(vm, 0); + + expect(result).toEqual({ + success: false, + shouldRetry: true, + shouldReload: true, + }); + expect(vm.scriptClickElement).toHaveBeenCalledWith( + 'button[data-testid="confirmationSheetConfirm"]', + ); + expect(vm.waitForRateLimit).toHaveBeenCalled(); }); }); @@ -175,6 +230,31 @@ describe("helpers_unfollow.ts", () => { ); }); + it("should not trigger an error and should keep the same index when rate limited", async () => { + // Rate limited after clicking confirm: the account wasn't actually unfollowed, + // so the job must retry it rather than report an error or move on. + vm.progress.isUnfollowEveryoneFinished = false; + mockElectron.X.isRateLimited.mockResolvedValue({ + isRateLimited: true, + rateLimitReset: 0, + }); + + const result = await DeleteHelpers.unfollowEveryoneProcessIteration( + vm, + 7, + 100, + ); + + expect(result.success).toBe(false); + expect(result.errorTriggered).toBe(false); + expect(result.errorType).toBe(null); + expect(result.shouldReload).toBe(true); + expect(result.newAccountIndex).toBe(7); // Same account gets retried + expect(vm.waitForRateLimit).toHaveBeenCalled(); + // The account should not be counted as unfollowed + expect(vm.progress.accountsUnfollowed).toBe(0); + }); + it("should keep same index when reload needed on error", async () => { vm.progress.isUnfollowEveryoneFinished = false; vi.spyOn(vm, "scriptMouseoverElementNth").mockResolvedValue(false); diff --git a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.ts b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.ts index 511a5031..62151c9a 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_unfollow.ts @@ -19,12 +19,13 @@ export async function unfollowEveryoneCheckIfFinished( /** * Unfollow a single account - * @returns Object with success flag and whether to reload page + * @returns Object with success flag, whether the account was rate limited and should be retried + * and whether to reload the page */ export async function unfollowEveryoneUnfollowAccount( vm: XViewModel, accountIndex: number, -): Promise<{ success: boolean; shouldReload: boolean }> { +): Promise<{ success: boolean; shouldRetry: boolean; shouldReload: boolean }> { // Mouseover the "Following" button on the next user if ( !(await vm.scriptMouseoverElementNth( @@ -32,7 +33,7 @@ export async function unfollowEveryoneUnfollowAccount( accountIndex, )) ) { - return { success: false, shouldReload: true }; + return { success: false, shouldRetry: false, shouldReload: true }; } // Click the unfollow button @@ -42,7 +43,7 @@ export async function unfollowEveryoneUnfollowAccount( accountIndex, )) ) { - return { success: false, shouldReload: true }; + return { success: false, shouldRetry: false, shouldReload: true }; } // Wait for confirm button @@ -52,11 +53,12 @@ export async function unfollowEveryoneUnfollowAccount( vm.rateLimitInfo = await window.electron.X.isRateLimited(vm.account.id); if (vm.rateLimitInfo.isRateLimited) { await vm.waitForRateLimit(); + return { success: false, shouldRetry: true, shouldReload: true }; } vm.log("unfollowEveryoneUnfollowAccount", [ "wait for confirm button failed", ]); - return { success: false, shouldReload: true }; + return { success: false, shouldRetry: false, shouldReload: true }; } // Click the confirm button @@ -65,10 +67,19 @@ export async function unfollowEveryoneUnfollowAccount( 'button[data-testid="confirmationSheetConfirm"]', )) ) { - return { success: false, shouldReload: true }; + return { success: false, shouldRetry: false, shouldReload: true }; } - return { success: true, shouldReload: false }; + // if we were rate limited the account wasn't actually unfollowed, so + // wait it out and retry instead of moving on. + await vm.sleep(500); + vm.rateLimitInfo = await window.electron.X.isRateLimited(vm.account.id); + if (vm.rateLimitInfo.isRateLimited) { + await vm.waitForRateLimit(); + return { success: false, shouldRetry: true, shouldReload: true }; + } + + return { success: true, shouldRetry: false, shouldReload: false }; } /** @@ -100,6 +111,17 @@ export async function unfollowEveryoneProcessIteration( // Unfollow the account const result = await unfollowEveryoneUnfollowAccount(vm, accountIndex); if (!result.success) { + // A rate limit isn't an error: we already waited it out, so just reload and retry + // this same account instead of ending the job. + if (result.shouldRetry) { + return { + success: false, + errorTriggered: false, + errorType: null, + shouldReload: result.shouldReload, + newAccountIndex: accountIndex, + }; + } return { success: false, errorTriggered: true,