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
103 changes: 103 additions & 0 deletions connectors/google/src/google-host.test.ts
Original file line numberDiff line numberDiff line change
@@ -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:<id> / tasks:<id>) 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<void>;
saveLink?: (link: unknown) => Promise<string | null>;
get: (channelId: string) => Promise<unknown>;
archiveLinks: (filter: Record<string, unknown>) => Promise<void>;
};
};
};

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" })
);
});
});
16 changes: 14 additions & 2 deletions connectors/google/src/google.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand DownExpand Up@@ -292,7 +293,13 @@ export class Google extends Connector<Google> {
// Clear an `invite-wait:<uid>` 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) =>
Expand DownExpand Up@@ -1017,8 +1024,13 @@ export class Google extends Connector<Google> {
get: <T>(key: string) => self._tasksHostGet<T>(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),
Expand Down
97 changes: 97 additions & 0 deletions connectors/google/src/host-integrations.test.ts
Original file line numberDiff line numberDiff line change
@@ -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");
});
});
66 changes: 66 additions & 0 deletions connectors/google/src/host-integrations.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;
};

/**
* 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<string | null>;
saveLinks(links: NewLinkWithNotes[]): Promise<void>;
channelSyncCompleted(channelId: string): Promise<void>;
archiveLinks(filter: ArchiveFilter): Promise<void>;
};

/**
* Wraps the integrations tool handed to a product host so persisted links
* carry product-namespaced channel ids ("calendar:<id>", "tasks:<id>") —
* 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);
},
};
}
57 changes: 57 additions & 0 deletions connectors/google/src/tasks/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
});
});
Loading
Loading