diff --git a/connectors/google/src/google-host.test.ts b/connectors/google/src/google-host.test.ts new file mode 100644 index 00000000..a107c1c2 --- /dev/null +++ b/connectors/google/src/google-host.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Google } from "./google"; + +/** + * Host-seam tests: the product hosts built by the connector must hand the + * products an integrations tool that namespaces persisted channel ids + * (calendar: / tasks:) while leaving token reads untouched. + */ +function makeConnector() { + const integrations = { + get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }), + saveLink: vi.fn().mockResolvedValue("thread-1"), + saveLinks: vi.fn().mockResolvedValue(undefined), + channelSyncCompleted: vi.fn().mockResolvedValue(undefined), + archiveLinks: vi.fn().mockResolvedValue(undefined), + }; + const tools = { + integrations, + store: { + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + listEntries: vi.fn().mockResolvedValue([]), + clearMany: vi.fn().mockResolvedValue(undefined), + }, + tasks: { runTask: vi.fn().mockResolvedValue(undefined) }, + callbacks: { create: vi.fn().mockResolvedValue("cb-token") }, + network: {}, + files: {}, + googleContacts: {}, + }; + const google = new Google("twist-1" as never, { + getTools: () => tools, + } as never); + return { google, integrations }; +} + +type HostWithIntegrations = { + tools: { + integrations: { + saveLinks?: (links: unknown[]) => Promise; + saveLink?: (link: unknown) => Promise; + get: (channelId: string) => Promise; + archiveLinks: (filter: Record) => Promise; + }; + }; +}; + +describe("makeCalendarHost integrations seam", () => { + it("saves calendar links under namespaced channel ids", async () => { + const { google, integrations } = makeConnector(); + const host = ( + google as unknown as { makeCalendarHost: () => HostWithIntegrations } + ).makeCalendarHost(); + await host.tools.integrations.saveLinks!([ + { channelId: "kris@plot.day", source: "s", type: "event" }, + ]); + expect(integrations.saveLinks).toHaveBeenCalledWith([ + expect.objectContaining({ channelId: "calendar:kris@plot.day" }), + ]); + }); + + it("reads tokens with the raw id the product passed", async () => { + const { google, integrations } = makeConnector(); + const host = ( + google as unknown as { makeCalendarHost: () => HostWithIntegrations } + ).makeCalendarHost(); + await host.tools.integrations.get("kris@plot.day"); + expect(integrations.get).toHaveBeenCalledWith("kris@plot.day"); + }); +}); + +describe("makeTasksHost integrations seam", () => { + it("saves task links under namespaced channel ids", async () => { + const { google, integrations } = makeConnector(); + const host = ( + google as unknown as { makeTasksHost: () => HostWithIntegrations } + ).makeTasksHost(); + await host.tools.integrations.saveLink!({ + channelId: "list1", + source: "s", + type: "task", + }); + expect(integrations.saveLink).toHaveBeenCalledWith( + expect.objectContaining({ channelId: "tasks:list1" }) + ); + }); + + it("archives BOTH eras when the tasks product archives a list", async () => { + const { google, integrations } = makeConnector(); + const host = ( + google as unknown as { makeTasksHost: () => HostWithIntegrations } + ).makeTasksHost(); + await host.tools.integrations.archiveLinks({ channelId: "list1" }); + expect(integrations.archiveLinks).toHaveBeenCalledWith( + expect.objectContaining({ channelId: "tasks:list1" }) + ); + expect(integrations.archiveLinks).toHaveBeenCalledWith( + expect.objectContaining({ channelId: "list1" }) + ); + }); +}); diff --git a/connectors/google/src/google.ts b/connectors/google/src/google.ts index c123baa8..d445bf64 100644 --- a/connectors/google/src/google.ts +++ b/connectors/google/src/google.ts @@ -84,6 +84,7 @@ import { Files } from "@plotday/twister/tools/files"; import { GOOGLE_SCOPES, PRODUCTS } from "./scopes"; import { composeChannels } from "./compose"; +import { withNamespacedChannelIds } from "./host-integrations"; import { parse } from "./product-channel"; import { PRODUCTS_BY_KEY } from "./products"; @@ -292,7 +293,13 @@ export class Google extends Connector { // Clear an `invite-wait:` marker once retracted or aged out. clearMailState: (key) => self._mailHostClear(key), tools: { - integrations: self.tools.integrations as any, + // Persisted links carry `calendar:`-namespaced channel ids so they + // match this connector's registered channels (topic seeding joins on + // exact equality). The product keeps raw calendar ids internally. + integrations: withNamespacedChannelIds( + self.tools.integrations as any, + "calendar" + ) as any, googleContacts: self.tools.googleContacts, store: { acquireLock: (key, ttlMs) => @@ -1017,8 +1024,13 @@ export class Google extends Connector { get: (key: string) => self._tasksHostGet(key), clear: (key) => self._tasksHostClear(key), tools: { + // Persisted links carry `tasks:`-namespaced channel ids so they + // match this connector's registered channels (see makeCalendarHost). // eslint-disable-next-line @typescript-eslint/no-explicit-any - integrations: self.tools.integrations as any, + integrations: withNamespacedChannelIds( + self.tools.integrations as any, + "tasks" + ) as any, }, scheduler: { queueSyncBatch: (listId) => self.tasksQueueSyncBatch(listId), diff --git a/connectors/google/src/host-integrations.test.ts b/connectors/google/src/host-integrations.test.ts new file mode 100644 index 00000000..35bf58e7 --- /dev/null +++ b/connectors/google/src/host-integrations.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { withNamespacedChannelIds } from "./host-integrations"; + +function mockIntegrations() { + return { + get: vi.fn(async () => ({ token: "t", scopes: [] as string[] })), + saveLink: vi.fn(async () => null), + saveLinks: vi.fn(async () => {}), + channelSyncCompleted: vi.fn(async () => {}), + archiveLinks: vi.fn(async () => {}), + }; +} + +// Minimal NewLinkWithNotes-shaped stub; the wrapper only touches channelId. +function link(channelId: string | null) { + return { channelId, source: "s", type: "event" } as never; +} + +describe("withNamespacedChannelIds", () => { + it("namespaces link.channelId on saveLink", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "tasks"); + await wrapped.saveLink(link("list1")); + expect(inner.saveLink).toHaveBeenCalledWith( + expect.objectContaining({ channelId: "tasks:list1" }) + ); + }); + + it("namespaces every link on saveLinks", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "calendar"); + await wrapped.saveLinks([link("kris@plot.day"), link("team@plot.day")]); + expect(inner.saveLinks).toHaveBeenCalledWith([ + expect.objectContaining({ channelId: "calendar:kris@plot.day" }), + expect.objectContaining({ channelId: "calendar:team@plot.day" }), + ]); + }); + + it("does not double-namespace an already-namespaced id", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "calendar"); + await wrapped.saveLink(link("calendar:cal1")); + expect(inner.saveLink).toHaveBeenCalledWith( + expect.objectContaining({ channelId: "calendar:cal1" }) + ); + }); + + it("namespaces a raw id that itself contains a colon", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "calendar"); + await wrapped.saveLink(link("foo:bar")); + expect(inner.saveLink).toHaveBeenCalledWith( + expect.objectContaining({ channelId: "calendar:foo:bar" }) + ); + }); + + it("leaves a null channelId alone", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "calendar"); + await wrapped.saveLink(link(null)); + expect(inner.saveLink).toHaveBeenCalledWith( + expect.objectContaining({ channelId: null }) + ); + }); + + it("archives BOTH data eras when filtering by channelId", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "calendar"); + await wrapped.archiveLinks({ channelId: "cal1", type: "event" }); + expect(inner.archiveLinks).toHaveBeenCalledWith({ + channelId: "calendar:cal1", + type: "event", + }); + expect(inner.archiveLinks).toHaveBeenCalledWith({ + channelId: "cal1", + type: "event", + }); + }); + + it("passes archiveLinks filters without channelId through once, unchanged", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "calendar"); + await wrapped.archiveLinks({ meta: { threadId: "x" } }); + expect(inner.archiveLinks).toHaveBeenCalledTimes(1); + expect(inner.archiveLinks).toHaveBeenCalledWith({ meta: { threadId: "x" } }); + }); + + it("passes token reads and sync-completed signals through untouched", async () => { + const inner = mockIntegrations(); + const wrapped = withNamespacedChannelIds(inner, "calendar"); + await wrapped.get("cal1"); + await wrapped.channelSyncCompleted("cal1"); + expect(inner.get).toHaveBeenCalledWith("cal1"); + expect(inner.channelSyncCompleted).toHaveBeenCalledWith("cal1"); + }); +}); diff --git a/connectors/google/src/host-integrations.ts b/connectors/google/src/host-integrations.ts new file mode 100644 index 00000000..5a2fba4b --- /dev/null +++ b/connectors/google/src/host-integrations.ts @@ -0,0 +1,66 @@ +import type { NewLinkWithNotes } from "@plotday/twister/plot"; + +import { namespace, parse } from "./product-channel"; + +type ArchiveFilter = { + channelId?: string; + type?: string; + status?: string; + meta?: Record; +}; + +/** + * The slice of the integrations tool the product hosts consume (see the + * `tools.integrations` blocks of `CalendarSyncHost` and `TasksSyncHost`). + */ +export type ChannelLinkIntegrations = { + get(channelId: string): Promise<{ token: string; scopes: string[] } | null>; + saveLink(link: NewLinkWithNotes): Promise; + saveLinks(links: NewLinkWithNotes[]): Promise; + channelSyncCompleted(channelId: string): Promise; + archiveLinks(filter: ArchiveFilter): Promise; +}; + +/** + * Wraps the integrations tool handed to a product host so persisted links + * carry product-namespaced channel ids ("calendar:", "tasks:") — + * matching how this connector registers its channels — while the product + * keeps using raw provider ids internally. Only calendar and tasks are ever + * wrapped: namespacing mail would key thread topics on Gmail labels, and a + * single user filing would then route all their Gmail by label. + * + * `archiveLinks` issues BOTH the namespaced and the raw filter because links + * saved before this change carry raw channel ids until the server-side + * backfill lands (transition safety; the double call is idempotent). + * + * Token reads (`get`) and sync-completed signals pass through untouched — + * the platform resolves both leniently for raw ids, and changing them would + * alter behavior beyond link persistence. + */ +export function withNamespacedChannelIds( + integrations: ChannelLinkIntegrations, + product: "calendar" | "tasks" +): ChannelLinkIntegrations { + const ns = (id: string | null): string | null => + id === null || parse(id).product === product ? id : namespace(product, id); + + return { + get: (channelId) => integrations.get(channelId), + saveLink: (link) => + integrations.saveLink({ ...link, channelId: ns(link.channelId) }), + saveLinks: (links) => + integrations.saveLinks( + links.map((l) => ({ ...l, channelId: ns(l.channelId) })) + ), + channelSyncCompleted: (channelId) => + integrations.channelSyncCompleted(channelId), + archiveLinks: async (filter) => { + if (!filter.channelId) return integrations.archiveLinks(filter); + await integrations.archiveLinks({ + ...filter, + channelId: namespace(product, filter.channelId), + }); + await integrations.archiveLinks(filter); + }, + }; +} diff --git a/connectors/google/src/tasks/sync.test.ts b/connectors/google/src/tasks/sync.test.ts index 0006ab16..9d9a03e5 100644 --- a/connectors/google/src/tasks/sync.test.ts +++ b/connectors/google/src/tasks/sync.test.ts @@ -466,3 +466,60 @@ describe("transformTask — to-do mapping (no link schedules)", () => { expect(link.author).toBeNull(); }); }); + +describe("onCreateLinkFn — namespaced compose channel ids", () => { + it("resolves the raw list id from a namespaced draft channelId", async () => { + const { host } = makeHost(); + vi.mocked(api.createTask).mockResolvedValue({ + id: "t9", + title: "Buy milk", + status: "needsAction", + } as never); + + const link = await onCreateLinkFn(host, { + type: "task", + channelId: `tasks:${LIST_ID}`, + title: "Buy milk", + status: "open", + noteContent: null, + contacts: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(api.createTask).toHaveBeenCalledWith( + expect.anything(), + LIST_ID, + expect.anything() + ); + // meta.listId feeds onLinkUpdatedFn's write-back — must be the raw id. + expect(link?.meta?.listId).toBe(LIST_ID); + // The persisted channel id keeps the platform's (namespaced) form. + expect(link?.channelId).toBe(`tasks:${LIST_ID}`); + }); + + it("keeps working with a raw draft channelId", async () => { + const { host } = makeHost(); + vi.mocked(api.createTask).mockResolvedValue({ + id: "t9", + title: "Buy milk", + status: "needsAction", + } as never); + + const link = await onCreateLinkFn(host, { + type: "task", + channelId: LIST_ID, + title: "Buy milk", + status: "open", + noteContent: null, + contacts: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(api.createTask).toHaveBeenCalledWith( + expect.anything(), + LIST_ID, + expect.anything() + ); + expect(link?.meta?.listId).toBe(LIST_ID); + }); +}); diff --git a/connectors/google/src/tasks/sync.ts b/connectors/google/src/tasks/sync.ts index 97d6b180..1d1f8056 100644 --- a/connectors/google/src/tasks/sync.ts +++ b/connectors/google/src/tasks/sync.ts @@ -27,6 +27,7 @@ import { import { Tag } from "@plotday/twister/tag"; import type { CreateLinkDraft } from "@plotday/twister/connector"; +import { parse } from "../product-channel"; import { createTask, isTaskListGoneError, @@ -606,12 +607,17 @@ export async function onCreateLinkFn( ): Promise { if (draft.type !== "task") return null; + // The compose picker hands over the REGISTERED channel id, which this + // connector namespaces ("tasks:"); the Google API needs the raw + // list id. `parse` leaves an already-raw id unchanged. + const listId = parse(draft.channelId).rawId; + const token = await getTokenFn(host, draft.channelId); const authActorId = await host.get("auth_actor_id"); let task: GoogleTask; try { - task = await createTask(token, draft.channelId, { + task = await createTask(token, listId, { title: draft.title, ...(draft.noteContent ? { notes: draft.noteContent } : {}), status: draft.status === "done" ? "completed" : "needsAction", @@ -649,7 +655,8 @@ export async function onCreateLinkFn( channelId: draft.channelId, meta: { taskId: task.id, - listId: draft.channelId, + // Raw list id: onLinkUpdatedFn routes its write-back through this. + listId, syncProvider: "google-tasks", channelId: draft.channelId, },