From babbc2b1b6b1ff13af652aecd0ad4ef9e053c010 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Tue, 30 Sep 2025 15:54:04 -0500 Subject: [PATCH] [PM-13422] Fix sync time (#16603) Do not update the "last sync time" when an error occurs during the sync process, including a network error when retrieving the account's revision date/time from the server. Update the sync time when a sync fires automatically, or when forced, in order to make it clear to the user that the extension's data is current. --- .../sync/default-sync.service.spec.ts | 95 +++++++++++++++++++ .../src/platform/sync/default-sync.service.ts | 6 +- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index 3c3e1c3677f..352e45b88b1 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -292,5 +292,100 @@ describe("DefaultSyncService", () => { expect(masterPasswordAbstraction.setMasterPasswordUnlockData).not.toHaveBeenCalled(); }); }); + + describe("mutate 'last update time'", () => { + let mockUserState: { update: jest.Mock }; + + const setupMockUserState = () => { + const mockUserState = { update: jest.fn() }; + jest.spyOn(stateProvider, "getUser").mockReturnValue(mockUserState as any); + return mockUserState; + }; + + const setupSyncScenario = (revisionDate: Date, lastSyncDate: Date) => { + jest.spyOn(apiService, "getAccountRevisionDate").mockResolvedValue(revisionDate.getTime()); + jest.spyOn(sut as any, "getLastSync").mockResolvedValue(lastSyncDate); + }; + + const expectUpdateCallCount = ( + mockUserState: { update: jest.Mock }, + expectedCount: number, + ) => { + if (expectedCount === 0) { + expect(mockUserState.update).not.toHaveBeenCalled(); + } else { + expect(mockUserState.update).toHaveBeenCalledTimes(expectedCount); + } + }; + + const defaultSyncOptions = { allowThrowOnError: true, skipTokenRefresh: true }; + const errorTolerantSyncOptions = { allowThrowOnError: false, skipTokenRefresh: true }; + + beforeEach(() => { + mockUserState = setupMockUserState(); + }); + + it("uses the current time when a sync is forced", async () => { + // Mock the value of this observable because it's used in `syncProfile`. Without it, the test breaks. + keyConnectorService.convertAccountRequired$ = of(false); + + // Baseline date/time to compare sync time to, in order to avoid needing to use some kind of fake date provider. + const beforeSync = Date.now(); + + // send it! + await sut.fullSync(true, defaultSyncOptions); + + expectUpdateCallCount(mockUserState, 1); + // Get the first and only call to update(...) + const updateCall = mockUserState.update.mock.calls[0]; + // Get the first argument to update(...) -- this will be the date callback that returns the date of the last successful sync + const dateCallback = updateCall[0]; + const actualTime = dateCallback() as Date; + + expect(Math.abs(actualTime.getTime() - beforeSync)).toBeLessThan(1); + }); + + it("updates last sync time when no sync is necessary", async () => { + const revisionDate = new Date(1); + setupSyncScenario(revisionDate, revisionDate); + + const syncResult = await sut.fullSync(false, defaultSyncOptions); + + // Sync should complete but return false since no sync was needed + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 1); + }); + + it("updates last sync time when sync is successful", async () => { + setupSyncScenario(new Date(2), new Date(1)); + + const syncResult = await sut.fullSync(false, defaultSyncOptions); + + expect(syncResult).toBe(true); + expectUpdateCallCount(mockUserState, 1); + }); + + describe("error scenarios", () => { + it("does not update last sync time when sync fails", async () => { + apiService.getSync.mockRejectedValue(new Error("not connected")); + + const syncResult = await sut.fullSync(true, errorTolerantSyncOptions); + + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 0); + }); + + it("does not update last sync time when account revision check fails", async () => { + jest + .spyOn(apiService, "getAccountRevisionDate") + .mockRejectedValue(new Error("not connected")); + + const syncResult = await sut.fullSync(false, errorTolerantSyncOptions); + + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 0); + }); + }); + }); }); }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 582e4b58a64..a02d602dbf0 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -134,9 +134,11 @@ export class DefaultSyncService extends CoreSyncService { const now = new Date(); let needsSync = false; + let needsSyncSucceeded = true; try { needsSync = await this.needsSyncing(forceSync); } catch (e) { + needsSyncSucceeded = false; if (allowThrowOnError) { this.syncCompleted(false, userId); throw e; @@ -144,7 +146,9 @@ export class DefaultSyncService extends CoreSyncService { } if (!needsSync) { - await this.setLastSync(now, userId); + if (needsSyncSucceeded) { + await this.setLastSync(now, userId); + } return this.syncCompleted(false, userId); }