Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions connectors/google/src/tasks/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,11 +55,15 @@ type HostHarness = {
saveLink: ReturnType<typeof vi.fn>;
};

function makeHost(): HostHarness {
function makeHost(options?: { token?: { token: string; scopes: string[] } | null }): HostHarness {
const store = new Map<string, unknown>();
const archiveLinks = vi.fn(async () => {});
const cancelScheduledTask = vi.fn(async () => {});
const saveLink = vi.fn(async () => "thread-id");
const token: { token: string; scopes: string[] } | null =
options && "token" in options
? (options.token ?? null)
: { token: "tok", scopes: [] };

const host: TasksSyncHost = {
id: "twist-instance-1",
Expand All@@ -73,7 +77,7 @@ function makeHost(): HostHarness {
},
tools: {
integrations: {
get: async () => ({ token: "tok", scopes: [] }),
get: async () => token,
saveLink,
channelSyncCompleted: vi.fn(async () => {}),
archiveLinks,
Expand DownExpand Up@@ -157,6 +161,62 @@ describe("periodicSyncBatchFn — deleted task list (404)", () => {
});
});

describe("periodicSyncBatchFn — connection needs re-auth (no token)", () => {
it("ends the cycle quietly instead of throwing", async () => {
// The account's OAuth grant lapsed or was revoked, so the channel resolves
// no token. The platform already flags the connection for re-auth (the app
// shows "Reconnect"), so this is an expected, user-actionable state — not a
// connector bug. Throwing from the hourly poll turns one lapsed connection
// into an error report every hour, multiplied by the queue's retries, for
// as long as it stays disconnected. Tasks is the only Google product that
// keeps hitting a dead token this way: mail and calendar are push-driven,
// and a revoked grant simply stops delivering notifications.
const { host, store, archiveLinks, cancelScheduledTask } = makeHost({
token: null,
});
store.set(`sync_enabled_${LIST_ID}`, true);
store.set(`periodic_sync_state_${LIST_ID}`, {
pageToken: null,
cycleStart: "2026-06-25T00:00:00.000Z",
} satisfies PeriodicSyncState);

const result = await periodicSyncBatchFn(host, LIST_ID);

expect(result).toEqual({ done: true });
// The in-flight cycle is dropped so the next poll starts a clean one...
expect(store.has(`periodic_sync_state_${LIST_ID}`)).toBe(false);
// ...but this is NOT a teardown: the list still exists and the channel is
// still enabled, so re-auth must resume syncing without re-enabling.
expect(cancelScheduledTask).not.toHaveBeenCalled();
expect(archiveLinks).not.toHaveBeenCalled();
expect(store.get(`sync_enabled_${LIST_ID}`)).toBe(true);
// last_sync_time is left untouched so the cycle skipped here is re-covered
// by the first poll after re-auth rather than silently lost.
expect(store.has(`last_sync_time_${LIST_ID}`)).toBe(false);
expect(api.listTasks).not.toHaveBeenCalled();
});
});

describe("syncBatchFn — connection needs re-auth (no token)", () => {
it("still throws, so the initial backfill does not silently latch", async () => {
// The initial backfill is different: the app shows a sync spinner until
// channelSyncCompleted fires, and a silent early return would leave it
// spinning forever. Throwing here is what drives the stuck-sync watchdog
// to surface "Reconnect", so this path must keep throwing.
const { host, store } = makeHost({ token: null });
store.set(`sync_state_${LIST_ID}`, {
pageToken: null,
batchNumber: 1,
tasksProcessed: 0,
initialSync: true,
} satisfies SyncState);

await expect(syncBatchFn(host, LIST_ID)).rejects.toThrow(
/authentication token/i
);
});
});

describe("onLinkUpdatedFn — deleted task/list (404)", () => {
it("swallows the 404 instead of throwing", async () => {
const { host } = makeHost();
Expand Down
44 changes: 43 additions & 1 deletion connectors/google/src/tasks/sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,6 +142,34 @@ export async function getTokenFn(
return token.token;
}

/**
* Like {@link getTokenFn}, but returns `null` instead of throwing when the
* channel resolves no auth token (lapsed / revoked Google OAuth).
*
* Use this on the recurring poll, where a missing token is an expected,
* already-surfaced state rather than a fault: reading it flags the connection
* for re-auth, so the app is already showing "Reconnect" and the user is the
* only one who can clear it. Throwing there instead reports one unhandled
* error per poll — multiplied by the queue's retries — for as long as the
* connection stays disconnected, which can be indefinitely.
*
* Google Tasks is the only product in this connector that hits a dead token
* this way: mail and calendar are push-driven, and a revoked grant simply
* stops delivering notifications. Tasks has no webhooks, so its hourly poll
* keeps knocking.
*
* Paths that must still surface a missing token — notably the initial
* backfill, where a silent early return would leave the app's sync spinner
* running forever — keep using the throwing {@link getTokenFn}.
*/
export async function tryGetTokenFn(
host: TasksSyncHost,
channelId: string
): Promise<string | null> {
const token = await host.tools.integrations.get(channelId);
return token?.token ?? null;
}

// ---------------------------------------------------------------------------
// Channel enable / disable (data-plane state)
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -353,7 +381,21 @@ export async function periodicSyncBatchFn(
if (!state) return { done: true };

const lastSync = await host.get<string>(`last_sync_time_${listId}`);
const token = await getTokenFn(host, listId);

// No token means the Google grant lapsed or was revoked. Reading it already
// flagged the connection for re-auth, so the user is seeing "Reconnect" and
// nothing this poll does can help until they act. Drop the in-flight cycle
// and report `done` rather than throwing: the poll is durably recurring, so
// it re-fires on its own and picks straight back up once the connection is
// restored. `last_sync_time_` is deliberately left untouched, so the window
// skipped here is re-covered by the first successful cycle instead of being
// silently lost.
const token = await tryGetTokenFn(host, listId);
if (!token) {
await host.clear(`periodic_sync_state_${listId}`);
return { done: true };
}

const authActorId = await host.get<ActorId>("auth_actor_id");

let result;
Expand Down
Loading